YTGUFVB4EIG5W6OMOMJELNFC2NM5GLIM5BPD6FXI3GDSXM6LIZPAC 22GVE2654AUPLXTR5D2CJTZPPDRB2MYHRDZW2EDTSNQXVVSZHA5AC LGYUP7U5UX7MOJGEMUD2UDAZZXAMJOE7GO5DEI5HVYAATG3SCIPQC BH6ZB7RYTWHHBSKYFUZNP7SJCRW3UIZHAKHSVCBPISTP3T35HCBAC 2UPYCKDRMU6JEYA524SWMNQM6KYWV3ZR67DKX56BJB4BWROZAGFQC IVJOIJKBZ5UT5NFOXD7VRPJ7OOWDONFOVP5WDDUUOP4BWHK5XDRAC WRBOA3JH5NUP47SV755EX77CAVDL4HGOVZZWF2BBB6FSPA3N3RQQC MGWNH72NNBWQCVZ7HSIFSUYHC4MSKJKLQA42UIR3VM6ULF3H2TZQC QZ4ZAYJJGG3NWG4KB5ZDDP6JAQX2UYDQX7PRKVIMPSAOBC6U543AC MTPFRBM4CQYIOAABKBYVSOW4KMRG6YD3U6ZC6D4XIQZ42LN5FSGQC F6WZWYNDIMFPVDTZC6SLW67IFJD5AKOU4SK5HMYHFCAKIJKZFQLAC HYYKAYWNTJDDFH3EX24BXXJ7LC6MDHPMIXTAMFX4CD76BMAR37MQC GMVYDVUUDEDLO4ORMNQUSEUDH37MZC3BUAHZS2F53CWTMFAYCVUAC let mut cmd = Command::new(server_bin);cmd.current_dir(repo.repo_root()).env("PIJUL_SRV_PORT", port.to_string()).env("PIJUL_SRV_REPO_DIR", repo.repo_root()).env("RUST_BACKTRACE", "1").stdout(Stdio::piped()).stderr(Stdio::piped());
let port = listener.local_addr().context("failed to read ephemeral listener address")?.port();
impl Drop for TestServer {fn drop(&mut self) {let _ = self.process.kill();let _ = self.process.wait();
fn wait_until_ready(port: u16) -> Result<()> {let addr: SocketAddr = ([127, 0, 0, 1], port).into();let deadline = Instant::now() + Duration::from_secs(5);loop {if TcpStream::connect_timeout(&addr, Duration::from_millis(50)).is_ok() {return Ok(());}if Instant::now() >= deadline {bail!("timed out waiting for server to listen on {addr}",);}std::thread::sleep(Duration::from_millis(20));
// `pijul init` creates a `main` channel. The lightweight `pijul_repository`// initializer doesn't guarantee that, so ensure one exists for protocol tests.let mut txn = repo.pristine.mut_txn_begin().context("failed to open pristine mut txn")?;txn.open_or_create_channel("main").context("failed to create main channel")?;txn.commit().context("failed to commit pristine txn")?;
use std::path::{Path, PathBuf};use std::process::{Command, Output};use anyhow::{Context, Result};use tempfile::TempDir;/// Isolated environment for running the `pijul` CLI in tests.////// This prevents tests from reading/writing the developer machine's config under/// `$HOME` / `XDG_CONFIG_HOME` / `XDG_DATA_HOME`.pub struct PijulEnv {temp_dir: TempDir,}impl PijulEnv {pub fn new() -> Result<Self> {Ok(Self {temp_dir: TempDir::new().context("failed to create temp dir for PijulEnv")?,})}pub fn home_dir(&self) -> &Path {self.temp_dir.path()}pub fn xdg_config_home(&self) -> PathBuf {self.temp_dir.path().join(".config")}pub fn xdg_data_home(&self) -> PathBuf {self.temp_dir.path().join(".local").join("share")}pub fn apply_env(&self, cmd: &mut Command) {cmd.env("HOME", self.home_dir()).env("XDG_CONFIG_HOME", self.xdg_config_home()).env("XDG_DATA_HOME", self.xdg_data_home());}}pub fn run_pijul(mut cmd: Command) -> Result<Output> {let output = cmd.output().context("failed to spawn pijul")?;anyhow::ensure!(output.status.success(),"pijul exited with {}.\nstdout:\n{}\nstderr:\n{}",output.status,String::from_utf8_lossy(&output.stdout),String::from_utf8_lossy(&output.stderr));Ok(output)}
assert_eq!(resp.status(), 404);assert_eq!(resp.text()?, "No command specified\n");
assert_eq!(resp.status(), 200);assert!(resp.headers().get("Content-Type").is_some_and(|t| t == "application/json"));let body: Value = serde_json::from_str(&resp.text()?)?;let channels = body.get("channels").and_then(|c| c.as_array()).expect("expected channels array");assert!(channels.iter().any(|c| c == "main"));assert!(body.get("head").is_some());
}// channel id{let resp = client.get(&url).query(&[("channel", "main"), ("id", "")]).send()?;assert_eq!(resp.status(), 200);assert!(resp.headers().get("Content-Type").is_some_and(|t| t == "application/octet-stream"));let bytes = resp.bytes()?;assert_eq!(bytes.len(), 16);}// channel state summary (JSON){let resp = client.get(&url).query(&[("channel", "main")]).send()?;assert_eq!(resp.status(), 200);assert!(resp.headers().get("Content-Type").is_some_and(|t| t == "application/json"));let body: Value = serde_json::from_str(&resp.text()?)?;assert!(body.get("position").and_then(|v| v.as_u64()).is_some());assert!(body.get("merkle").and_then(|v| v.as_str()).is_some());assert!(body.get("tag_merkle").and_then(|v| v.as_str()).is_some());}// state at position{let resp = client.get(&url).query(&[("state", "0"), ("channel", "main")]).send()?;assert_eq!(resp.status(), 200);assert!(resp.headers().get("Content-Type").is_some_and(|t| t == "text/plain"));let body = resp.text()?;let parts: Vec<&str> = body.split_whitespace().collect();assert_eq!(parts.len(), 3);assert!(parts[0].parse::<u64>().is_ok());}// changelist from position{let resp = client.get(&url).query(&[("changelist", "0"), ("channel", "main")]).send()?;assert_eq!(resp.status(), 200);assert!(resp.headers().get("Content-Type").is_some_and(|t| t == "text/plain"));assert_eq!(resp.text()?, "");}// change download{let resp = client.get(&url).query(&[("change", "abcdef123")]).send()?;assert_eq!(resp.status(), 200);assert!(resp.headers().get("Cache-Control").is_some_and(|v| v == "public, max-age=31536000, immutable"));let bytes = resp.bytes()?.to_vec();assert_eq!(bytes, b"change data");}// tag download{let resp = client.get(&url).query(&[("tag", "abcdef123")]).send()?;assert_eq!(resp.status(), 200);assert!(resp.headers().get("Cache-Control").is_some_and(|v| v == "public, max-age=31536000, immutable"));let bytes = resp.bytes()?.to_vec();assert_eq!(bytes, b"tag data");}// invalid query => 400{let resp = client.get(&url).query(&[("changelist", "0")]).send()?;assert_eq!(resp.status(), 400);
#[test]fn pijul_cli_clone_and_pull_over_http() -> Result<()> {// Remote repo with actual recorded changes, served over HTTP.let remote = RepoFixture::new()?;remote.write_file("hello.txt", "v1\n")?;{let mut cmd = std::process::Command::new("pijul");cmd.current_dir(remote.repo_root()).arg("add").arg("hello.txt");remote.apply_env(&mut cmd);run_pijul(cmd)?;}{let mut cmd = std::process::Command::new("pijul");cmd.current_dir(remote.repo_root()).arg("record").arg("--all").arg("--message").arg("init").arg("--identity").arg("test").arg("--no-prompt");remote.apply_env(&mut cmd);run_pijul(cmd)?;}let server = TestServer::start(remote)?;// Clone into a separate isolated HOME.let env = PijulEnv::new()?;let clone_parent = assert_fs::TempDir::new()?;let clone_dir = clone_parent.child("clone");{let mut cmd = std::process::Command::new("pijul");cmd.current_dir(clone_parent.path()).arg("clone").arg(server.base_url()).arg(clone_dir.path()).arg("--no-prompt");env.apply_env(&mut cmd);run_pijul(cmd)?;}clone_dir.child("hello.txt").assert("v1\n");// Add another change on the remote while the server is running.std::fs::write(server.repo_root().join("hello.txt"), "v2\n")?;{let mut cmd = std::process::Command::new("pijul");cmd.current_dir(server.repo_root()).arg("record").arg("--all").arg("--message").arg("update").arg("--identity").arg("test").arg("--no-prompt");server.apply_repo_env(&mut cmd);run_pijul(cmd)?;}// Pull into the clone.{let mut cmd = std::process::Command::new("pijul");cmd.current_dir(clone_dir.path()).arg("pull").arg(server.base_url()).arg("--all").arg("--no-prompt");env.apply_env(&mut cmd);run_pijul(cmd)?;}clone_dir.child("hello.txt").assert("v2\n");Ok(())}
if let Some(from) = map.get("changelist").and_then(|v| v.first()) {if let Some(channel) = map.get("channel").and_then(|v| v.first()) {return Operation::Changelist {channel: channel.clone(),from: from.parse().unwrap_or(0),};}
if let Some(from) = map.get("changelist").and_then(|v| v.first())&& let Some(channel) = map.get("channel").and_then(|v| v.first()){return Operation::Changelist {channel: channel.clone(),from: from.parse().unwrap_or(0),};
if map.contains_key("id") {if let Some(channel) = map.get("channel").and_then(|v| v.first()) {return Operation::ChannelId {channel: channel.clone(),};}
if map.contains_key("id")&& let Some(channel) = map.get("channel").and_then(|v| v.first()){return Operation::ChannelId {channel: channel.clone(),};
////// # Returns/// Tuple of (path, Operation) where:/// - `path`: URL path with leading `/` stripped (e.g., `".pijul"`)/// - `Operation`: The protocol operation derived from query parameters////// # Example/// ```/// use pijul_srv_lite::url::{parse_url, Operation};/// let (path, op) = parse_url("/.pijul?channel=main&id");/// assert_eq!(path, ".pijul");/// assert_eq!(op, Operation::ChannelId { channel: "main".into() });/// ```
use crate::router;use std::io;use std::net::TcpListener;use std::path::PathBuf;use tiny_http::{Method, Server};use crate::error::{Result, ServerError};use crate::responses;pub fn resolve_repo_path(repo_base: PathBuf) -> Result<PathBuf> {let repo_path = if repo_base.join(".pijul").exists() {repo_base} else {repo_base.join("default")};if !repo_path.join(".pijul").exists() {return Err(ServerError::RepoNotFound(repo_path));}Ok(repo_path)}pub struct ServerConfig {pub repo_path: PathBuf,}pub fn serve(listener: TcpListener, config: ServerConfig) -> Result<()> {let local_addr = listener.local_addr()?;let server =Server::from_listener(listener, None).map_err(|e| io::Error::other(e.to_string()))?;println!("listening on {local_addr}");println!("Serving repository from {:?}", config.repo_path);for request in server.incoming_requests() {let response = match request.method() {Method::Head => responses::empty(200),Method::Get | Method::Post => router::route(request.url(), &config),_ => responses::status_code(405),};request.respond(response)?;}Ok(())}
use std::path::Path;use crate::handlers;use crate::pristine::PristineReader;use crate::responses;use crate::server::ServerConfig;use crate::url::{Operation, parse_url};fn with_reader(repo_path: &Path,f: impl FnOnce(&PristineReader) -> responses::HttpResponse,) -> responses::HttpResponse {let Ok(reader) = PristineReader::open(repo_path) else {return responses::status_code(500);};f(&reader)}pub(crate) fn route(url: &str, config: &ServerConfig) -> responses::HttpResponse {let (path, op) = parse_url(url);let repo_path = config.repo_path.as_path();if !path.starts_with(".pijul") {return responses::status_code(404);}match op {Operation::Info => with_reader(repo_path, handlers::serve_info),Operation::DownloadChange { hash } => handlers::serve_change(repo_path, &hash),Operation::DownloadTag { hash } => handlers::serve_tag(repo_path, &hash),Operation::Changelist { channel, from } => with_reader(repo_path, |reader| {handlers::serve_changelist(reader, &channel, from)}),Operation::ChannelId { channel } => with_reader(repo_path, |reader| {handlers::serve_channel_id(reader, &channel)}),Operation::ChannelState { channel } => with_reader(repo_path, |reader| {handlers::serve_channel(reader, &channel)}),Operation::State { channel, pos } => with_reader(repo_path, |reader| {handlers::serve_state(reader, &channel, pos)}),Operation::Identities => handlers::serve_identities(),Operation::Invalid => responses::status_code(400),}}
pub struct PristineReader {}
use std::path::Path;use libpijul::pristine::{Base32, Merkle, Pair, TxnT};use libpijul::{ChannelTxnT, GraphTxnT};use pijul_repository::Repository;/// Read-only view of a repository's pristine database.////// This is a small wrapper around `pijul_repository::Repository` so we don't/// depend on Sanakirja directly in this crate.pub(crate) struct PristineReader {repo: Repository,}
dbg!(channel, from);todo!()
let Ok(txn) = self.repo.pristine.txn_begin() else {return Vec::new();};let Ok(Some(chan_ref)) = txn.load_channel(channel) else {return Vec::new();};let chan = chan_ref.read();let Ok(cursor) = libpijul::pristine::changeid_log(&txn, &chan, from.into()) else {return Vec::new();};let mut out = Vec::new();for item in cursor {let Ok((pos,Pair {a: change_id,b: state,},)) = itemelse {continue;};let pos: u64 = (*pos).into();let Some(ext_hash) = txn.get_external(change_id).ok().flatten() else {continue;};let hash: libpijul::Hash = ext_hash.into();let merkle = Merkle::from(state).to_base32();let is_tag = txn.is_tagged(txn.tags(&chan), pos).unwrap_or(false);out.push(ChangeEntry {position: pos,hash: hash.to_base32(),merkle,is_tag,});}out
dbg!(channel, n);todo!()
let txn = self.repo.pristine.txn_begin().ok()?;let chan_ref = txn.load_channel(channel).ok()??;let chan = chan_ref.read();// Merkle for the change history at (or before) `n`.let (pos, merkle) = {let mut cur = txn.rev_cursor_revchangeset(txn.rev_changes(&chan), Some(n.into())).ok()?;if let Some(entry) = cur.next() {let (pos, Pair { b: m, .. }) = entry.ok()?;let pos: u64 = (*pos).into();(pos, Merkle::from(m).to_base32())} else {(0, Merkle::zero().to_base32())}};// Merkle for the tag history at (or before) `n`.let tag_merkle = {let mut cur = txn.rev_iter_tags(txn.tags(&chan), Some(n)).ok()?;if let Some(entry) = cur.next() {let (_pos, Pair { b: m, .. }) = entry.ok()?;Merkle::from(m).to_base32()} else {Merkle::zero().to_base32()}};Some((pos, merkle, tag_merkle))
todo!()
let Ok(txn) = self.repo.pristine.txn_begin() else {return Vec::new();};let Ok(channels) = txn.channels("") else {return Vec::new();};channels.into_iter().map(|c| c.read().name.as_str().to_string()).collect()}pub fn channel_id_bytes(&self, channel: &str) -> Option<[u8; 16]> {let txn = self.repo.pristine.txn_begin().ok()?;let chan_ref = txn.load_channel(channel).ok()??;let chan = chan_ref.read();Some(*chan.id.as_bytes())
pub mod handlers;pub mod pristine;pub mod responses;
pub mod error;pub(crate) mod handlers;pub(crate) mod pristine;pub(crate) mod responses;pub(crate) mod router;pub mod server;
pub fn serve_channel_id(_reader: &PristineReader, _channel: &str) -> HttpResponse {todo!()
pub(crate) fn serve_channel_id(reader: &PristineReader, channel: &str) -> HttpResponse {let Some(id) = reader.channel_id_bytes(channel) else {return responses::status_code(404);};responses::binary(id.to_vec())
/// # Special Return/// Returns "-" if the requested position doesn't existpub fn serve_state(reader: &PristineReader, channel: &str, n: u64) -> HttpResponse {
/// # Position Semantics/// If `n` doesn't exist exactly, the server returns the latest state at or/// before `n`. For an empty channel, this returns position `0` with the/// zero merkle hashes.pub(crate) fn serve_state(reader: &PristineReader, channel: &str, n: u64) -> HttpResponse {
Some((pos, merkle, tag_merkle)) => Response::from_string(format!("{} {} {}", pos, merkle, tag_merkle)),None => responses::status_code(400)
Some((pos, merkle, tag_merkle)) => responses::text(&format!("{pos} {merkle} {tag_merkle}")),None => responses::status_code(404),
/// Can't get this to work with nest.pijul.org.
/// # Pijul Client Interaction/// - **Endpoint**: `GET /.pijul?tag={hash}`/// - **Called by**: `Http::download_tags()` in pijul-remote/src/http.rs (when tags exist)/// - **Response format**: Binary tag file content (`application/octet-stream`)
/// On nest.pijul.org, the tags tab for repos are empty.pub fn serve_tag(repo_path: &Path, hash: &str) -> HttpResponse {dbg!(repo_path, hash);todo!("No reference implementation")
/// # 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(crate) fn serve_tag(repo_path: &Path, hash: &str) -> HttpResponse {if hash.len() < 3 || !hash.chars().all(|c| c.is_ascii_alphanumeric()) {return Response::from_string("invalid tag hash").with_status_code(404);}let (pfx, sfx) = hash.split_at(2);let path = repo_path.join(".pijul").join("changes").join(pfx).join(format!("{}.tag", sfx));responses::file(&path, "application/octet-stream")
// #[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_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");
#[test]fn test_serve_tag_valid_hash_file_exists() {let dir = TempDir::new().unwrap();let tag_dir = dir.path().join(".pijul").join("changes").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();
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")// );// }
let resp = serve_tag(dir.path(), "abcdef123");assert_eq!(get_status(&resp), 200);assert_eq!(get_header(&resp, "Content-Type"),Some("application/octet-stream"));assert_eq!(get_header(&resp, "Cache-Control"),Some("public, max-age=31536000, immutable"));}
use std::io;use std::path::PathBuf;use thiserror::Error;#[derive(Debug, Error)]pub enum ServerError {#[error(transparent)]Io(#[from] io::Error),#[error("repository not found at {0:?}")]RepoNotFound(PathBuf),}pub type Result<T> = std::result::Result<T, ServerError>;
use pijul_srv_lite::handlers::{serve_change, serve_changelist, serve_channel, serve_channel_id, serve_identities, serve_info,serve_state, serve_tag,};use pijul_srv_lite::pristine::PristineReader;use pijul_srv_lite::responses::{self, HttpResponse};use pijul_srv_lite::url::{Operation, parse_url};use std::path::{Path, PathBuf};use tiny_http::Response;
use pijul_srv_lite::error::Result;use pijul_srv_lite::server::{ServerConfig, resolve_repo_path, serve};use std::{env, net::TcpListener, path::PathBuf};
let port = std::env::var("PIJUL_SRV_PORT")
if let Err(err) = run() {eprintln!("{err:#}");std::process::exit(1);}}fn run() -> Result<()> {let bind_addr =std::env::var("PIJUL_SRV_BIND_ADDR").unwrap_or_else(|_| "127.0.0.1".to_string());let port: u16 = env::var("PIJUL_SRV_PORT")
let server = tiny_http::Server::http(format!("0.0.0.0:{port}")).unwrap();println!("{SERVER_NAME} listening on port {port}");println!("Serving repository from {:?}", repo_path);for request in server.incoming_requests() {if request.method() == &tiny_http::Method::Head {let _ = request.respond(Response::empty(200));continue;}
println!("Starting {SERVER_NAME}");
fn handle_request(url: &str, repo_path: &Path) -> HttpResponse {let (path, op) = parse_url(url);if !path.starts_with(".pijul") {return responses::status_code(404);}let reader = match PristineReader::open(repo_path) {Ok(r) => r,Err(_) => return responses::status_code(500),};match op {Operation::DownloadChange { hash } => serve_change(repo_path, &hash),Operation::DownloadTag { hash } => serve_tag(&repo_path, &hash),Operation::Changelist { channel, from } => serve_changelist(&reader, &channel, from),Operation::ChannelId { channel } => serve_channel_id(&reader, &channel),Operation::ChannelState { channel } => serve_channel(&reader, &channel),Operation::State { channel, pos } => serve_state(&reader, &channel, pos),Operation::Identities => serve_identities(),Operation::NoCommandSpecified => Response::from_string("No command specified\n").with_status_code(404),Operation::Invalid => responses::status_code(500),}}
name = "assert_fs"version = "1.1.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a652f6cb1f516886fcfee5e7a5c078b9ade62cfcb889524efe5a64d682dd27a9"dependencies = ["anstyle","doc-comment","globwalk","predicates","predicates-core","predicates-tree","tempfile",][[package]]
name = "predicates"version = "3.1.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe"dependencies = ["anstyle","difflib","float-cmp","normalize-line-endings","predicates-core","regex",][[package]]name = "predicates-core"version = "1.0.10"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144"[[package]]name = "predicates-tree"version = "1.0.13"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2"dependencies = ["predicates-core","termtree",][[package]]
version = "1.4.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "81aaf70d064e2122209f04d01fd91e8908e7a327b516236e1cbc0c3f34ac6d11"dependencies = ["fs2","log","memmap2","parking_lot 0.11.2","sanakirja-core 1.4.1","serde","thiserror 1.0.69",][[package]]name = "sanakirja"