//! HTTP request handlers for pijul remote protocol endpoints.
//!
//! These handlers implement the server-side of the pijul HTTP remote protocol
//! as defined in pijul-remote/src/http.rs. The pijul client makes requests to
//! `{url}/.pijul?{query}` and these handlers respond with the appropriate data.
//!
//! Protocol version: 3 (PROTOCOL_VERSION in pijul-remote/src/lib.rs)
use std::path::Path;
use crate::pristine::PristineReader;
use crate::responses::{HttpResponse, not_found, ok_json, ok_text, serve_file};
/// Serve channel RemoteId (16 bytes binary).
///
/// # Pijul Client Interaction
/// - **Endpoint**: `GET /.pijul?channel={name}&id`
/// - **Called by**: `Http::get_remote_id()` in pijul-remote/src/http.rs
/// - **Response format**: Raw 16-byte binary RemoteId
/// - **Purpose**: The client uses RemoteId to identify and cache remote state.
/// When syncing, pijul checks if the remote's ID matches the cached ID to
/// determine if it can use cached changelist data.
///
/// # Errors
/// Returns 404 if the channel doesn't exist.
pub fn serve_channel_id(_reader: &PristineReader, _channel: &str) -> HttpResponse {
todo!()
}
/// Serve channel state information as JSON.
///
/// # Pijul Client Interaction
/// - **Endpoint**: `GET /.pijul?channel={name}` (without `id` flag)
/// - **Response format**: JSON `{"position":N,"merkle":"...","tag_merkle":"..."}`
/// - **Purpose**: Provides the current state of a channel at position 0.
/// Note: The standard pijul client typically uses `get_state()` instead,
/// but this endpoint provides a convenient JSON summary.
///
/// # Errors
/// Returns 404 if the channel doesn't exist.
pub fn serve_channel(reader: &PristineReader, channel: &str) -> HttpResponse {
// Return channel state at position 0
match reader.get_state(channel, 0) {
Some((pos, merkle, tag_merkle)) => {
let json = format!(
r#"{{"position":{},"merkle":"{}","tag_merkle":"{}"}}"#,
pos, merkle, tag_merkle
);
ok_json(&json)
}
None => not_found("channel not found"),
}
}
/// Serve changelist starting from a given position.
///
/// # Pijul Client Interaction
/// - **Endpoint**: `GET /.pijul?changelist={from}&channel={name}`
/// - **Called by**: `Http::download_changelist()` in pijul-remote/src/http.rs
/// - **Response format**: Text, one entry per line:
/// - Format: `{position}.{hash}.{merkle}` (no trailing `.` = regular change)
/// - Format: `{position}.{hash}.{merkle}.` (trailing `.` = tag)
/// - **Client parsing regex**: `(?P<num>[0-9]+)\.(?P<hash>[A-Za-z0-9]+)\.(?P<merkle>[A-Za-z0-9]+)(?P<tag>\.)?`
/// - **Purpose**: The client requests changelist to discover which changes exist
/// on the remote that it doesn't have locally. It starts from position 0 for
/// a fresh clone or from its last known position for incremental sync.
///
/// # Data Sources
/// - Reads from channel's `revchanges` btree (position -> ChangeId + Merkle)
/// - Looks up change hashes in the `EXTERNAL` table (ChangeId -> SerializedHash)
/// - Checks `tags` btree to determine if an entry is a tag
pub fn serve_changelist(reader: &PristineReader, channel: &str, from: u64) -> HttpResponse {
let entries = reader.get_changelist(channel, from);
let output: String = entries
.iter()
.map(|e| {
let suffix = if e.is_tag { "." } else { "" };
format!("{}.{}.{}{}", e.position, e.hash, e.merkle, suffix)
})
.collect::<Vec<_>>()
.join("\n");
ok_text(output)
}
/// Serve a change file by its base32-encoded hash.
///
/// # Pijul Client Interaction
/// - **Endpoint**: `GET /.pijul?change={hash}`
/// - **Called by**: `Http::download_changes()` in pijul-remote/src/http.rs
/// - **Response format**: Binary change file content (`application/octet-stream`)
/// - **Purpose**: Downloads individual change files during clone/pull operations.
/// The client discovers needed changes via `changelist`, then downloads each
/// missing change via this endpoint. Downloads occur in parallel (up to
/// POOL_SIZE=20 concurrent requests in the pijul client).
///
/// # File Location
/// Changes are stored at: `.pijul/changes/{first-2-chars}/{rest}.change`
/// Example: hash `ABCDEF123` -> `.pijul/changes/AB/CDEF123.change`
///
/// # Caching
/// Response includes `Cache-Control: public, max-age=31536000, immutable`
/// because change files are content-addressed and never change.
///
/// # Validation
/// Hash must be at least 3 characters and contain only alphanumeric chars.
/// Returns 404 for invalid hashes or missing files.
pub fn serve_change(repo_path: &Path, hash: &str) -> HttpResponse {
if hash.len() < 3 || !hash.chars().all(|c| c.is_ascii_alphanumeric()) {
return not_found("invalid change hash");
}
let (pfx, sfx) = hash.split_at(2);
let path = repo_path
.join(".pijul")
.join("changes")
.join(pfx)
.join(format!("{}.change", sfx));
serve_file(&path, "application/octet-stream")
}
/// Serve channel state at a specific position.
///
/// # Pijul Client Interaction
/// - **Endpoint**: `GET /.pijul?state={position}&channel={name}`
/// - **Called by**: `Http::get_state()` in pijul-remote/src/http.rs
/// - **Response format**: Space-separated text: `{position} {merkle} {tag_merkle}`
/// - **Purpose**: The client uses this to verify remote state and determine
/// sync boundaries. The merkle hash allows efficient comparison of repository
/// states without transferring all changes.
///
/// # Data Sources
/// - Reads from channel's `revchanges` btree for merkle at position N
/// - Reads from channel's `tags` btree for tag_merkle at position N
///
/// # Special Return
/// Returns "-" if the requested position doesn't exist
pub fn serve_state(reader: &PristineReader, channel: &str, n: u64) -> HttpResponse {
match reader.get_state(channel, n) {
Some((pos, merkle, tag_merkle)) => ok_text(format!("{} {} {}", pos, merkle, tag_merkle)),
None => ok_text("-".to_string()), // Spec says return "-" if not found
}
}
/// Serve a tag (state) file by its base32-encoded hash.
///
/// # Pijul Client Interaction
/// - **Endpoint**: `GET /.pijul?tag={hash}`
/// - **Called by**: `Http::download_changes()` in pijul-remote/src/http.rs (for tags)
/// - **Response format**: Binary tag file content (`application/octet-stream`)
/// - **Purpose**: Downloads tag/state files. The changelist response
/// indicates which entries are tags (trailing `.`), and the client downloads
/// these via this endpoint.
///
/// # File Location
/// Tags are stored at: `.pijul/tags/{first-2-chars}/{rest}.tag`
/// Example: hash `XYZABC789` -> `.pijul/tags/XY/ZABC789.tag`
///
/// # Caching
/// Response includes `Cache-Control: public, max-age=31536000, immutable`
/// because tag files are content-addressed and never change.
///
/// # Validation
/// Hash must be at least 3 characters and contain only alphanumeric chars.
/// Returns 404 for invalid hashes or missing files.
pub fn serve_tag(repo_path: &Path, hash: &str) -> HttpResponse {
if hash.len() < 3 || !hash.chars().all(|c| c.is_ascii_alphanumeric()) {
return not_found("invalid tag hash");
}
let (pfx, sfx) = hash.split_at(2);
let path = repo_path
.join(".pijul")
.join("tags")
.join(pfx)
.join(format!("{}.tag", sfx));
serve_file(&path, "application/octet-stream")
}
/// Serve identity certificates from the repository.
///
/// # Pijul Client Interaction
/// - **Endpoint**: `GET /.pijul?identities` or `GET /.pijul?identities={rev}`
/// - **Called by**: `Http::update_identities()` in pijul-remote/src/http.rs
/// - **Response format**: JSON `{"id":[...],"rev":N}`
/// - `id`: Array of identity certificates (currently empty)
/// - `rev`: Revision number for incremental updates
/// - **Purpose**: Pijul supports cryptographic identity verification. The client
/// requests identities to verify change authorship and establish trust.
/// The `rev` parameter allows incremental updates (only fetch new identities
/// since a given revision).
///
/// # Current Implementation
/// Returns empty identity list. A full implementation would read from
/// `.pijul/identities/` and return serialized identity certificates.
pub fn serve_identities() -> HttpResponse {
ok_json(r#"{"id":[],"rev":0}"#)
}
/// Serve repository information including channel list.
///
/// # Pijul Client Interaction
/// - **Endpoint**: `GET /.pijul` (no query) or `GET /.pijul?log`
/// - **Response format**: JSON `{"channels":["main",...],"head":null}`
/// - `channels`: Array of channel names in the repository
/// - `head`: Currently always null (reserved for future use)
/// - **Purpose**: Provides discovery information about the repository.
/// Clients can use this to enumerate available channels before cloning
/// or pulling. The `?log` variant exists for compatibility but returns
/// the same information.
///
/// # Data Sources
/// - Reads channel names from the `CHANNELS` root table in the pristine database
pub fn serve_info(reader: &PristineReader) -> HttpResponse {
let channels = reader.list_channels();
let mut out = String::from("{");
if !channels.is_empty() {
let names: Vec<String> = channels.iter().map(|s| format!("\"{s}\"")).collect();
out.push_str("\"channels\":[");
out.push_str(&names.join(","));
out.push_str("],");
}
out.push_str("\"head\":null}");
ok_json(&out)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::io::Write;
use tempfile::TempDir;
fn get_status(resp: &HttpResponse) -> u16 {
resp.status_code().0
}
fn get_header<'a>(resp: &'a HttpResponse, name: &str) -> Option<&'a str> {
resp.headers()
.iter()
.find(|h| h.field.as_str().as_str().eq_ignore_ascii_case(name))
.map(|h| h.value.as_str())
}
// serve_change
#[test]
fn test_serve_change_hash_too_short() {
let dir = TempDir::new().unwrap();
let resp = serve_change(dir.path(), "ab");
assert_eq!(get_status(&resp), 404);
}
#[test]
fn test_serve_change_hash_empty() {
let dir = TempDir::new().unwrap();
let resp = serve_change(dir.path(), "");
assert_eq!(get_status(&resp), 404);
}
#[test]
fn test_serve_change_hash_invalid_chars() {
let dir = TempDir::new().unwrap();
let resp = serve_change(dir.path(), "abc-def");
assert_eq!(get_status(&resp), 404);
}
#[test]
fn test_serve_change_hash_with_slash() {
let dir = TempDir::new().unwrap();
let resp = serve_change(dir.path(), "ab/cdef");
assert_eq!(get_status(&resp), 404);
}
#[test]
fn test_serve_change_valid_hash_file_missing() {
let dir = TempDir::new().unwrap();
let resp = serve_change(dir.path(), "abcdef123");
assert_eq!(get_status(&resp), 404);
}
#[test]
fn test_serve_change_valid_hash_file_exists() {
let dir = tempfile::TempDir::new().unwrap();
let change_dir = dir.path().join(".pijul").join("changes").join("ab");
fs::create_dir_all(&change_dir).unwrap();
let change_file = change_dir.join("cdef123.change");
fs::File::create(&change_file)
.unwrap()
.write_all(b"change data")
.unwrap();
let resp = serve_change(dir.path(), "abcdef123");
assert_eq!(get_status(&resp), 200);
assert_eq!(
get_header(&resp, "Content-Type"),
Some("application/octet-stream")
);
}
// serve_tag
#[test]
fn test_serve_tag_hash_too_short() {
let dir = TempDir::new().unwrap();
let resp = serve_tag(dir.path(), "ab");
assert_eq!(get_status(&resp), 404);
}
#[test]
fn test_serve_tag_valid_hash_file_exists() {
let dir = TempDir::new().unwrap();
let tag_dir = dir.path().join(".pijul").join("tags").join("ab");
fs::create_dir_all(&tag_dir).unwrap();
let tag_file = tag_dir.join("cdef123.tag");
fs::File::create(&tag_file)
.unwrap()
.write_all(b"tag data")
.unwrap();
let resp = serve_tag(dir.path(), "abcdef123");
assert_eq!(get_status(&resp), 200);
assert_eq!(
get_header(&resp, "Content-Type"),
Some("application/octet-stream")
);
}
// serve_identities tests
#[test]
fn test_serve_identities() {
let resp = serve_identities();
assert_eq!(get_status(&resp), 200);
assert_eq!(get_header(&resp, "Content-Type"), Some("application/json"));
}
}