//! Instance HTTP server. //! //! Hosts repositories under a configured root directory. Each repository //! lives at `//` with the standard `.levcs/` layout. //! //! Implements the §5.2 endpoint surface: //! //! ```text //! GET /levcs/v1/repos/{repo_id}/info //! GET /levcs/v1/repos/{repo_id}/objects/{hash} //! GET /levcs/v1/repos/{repo_id}/pack?have=...&want=... //! POST /levcs/v1/repos/{repo_id}/push //! GET /levcs/v1/repos/{repo_id}/refs //! POST /levcs/v1/repos/{repo_id}/init //! GET /levcs/v1/instance/info //! GET /levcs/v1/instance/peers //! ``` pub mod mirror; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::{Arc, Mutex, RwLock}; use axum::body::Bytes; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::Router; use serde::{Deserialize, Serialize}; use levcs_core::object::ObjectType; use levcs_core::{Commit, EntryType, ObjectId, ObjectStore, Tree}; use levcs_identity::authority::AuthorityBody; use levcs_identity::keys::PublicKey; use levcs_identity::verify::{ verify_authority_chain, verify_genesis, ObjectSource as VerifySource, }; use levcs_merge::engine::check_handler_allowed; use levcs_merge::record::MergeRecord; use levcs_protocol::auth::{verify_request, AuthRequest, DEFAULT_CLOCK_SKEW, NONCE_TTL_SECS}; use levcs_protocol::wire::{InfoResponse, InstanceInfo, RefList}; use levcs_protocol::Pack; #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct InstanceConfig { pub root: PathBuf, #[serde(default)] pub storage_mode: String, // full, release, metadata #[serde(default)] pub federation_peers: Vec, #[serde(default)] pub allowed_handlers: Vec, /// Per-repository mirror declarations (§5.6). A repo whose `repo_id` /// matches one of these entries is treated as a mirror of `source` — /// served read-only to clients (unless `writeback` is true) and kept /// fresh by `sync_mirror`. #[serde(default)] pub mirrors: Vec, } /// Per-repository mirror configuration (§5.6). #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct MirrorConfig { pub repo_id: String, /// Base URL of the source instance, including the `/levcs/v1` path. pub source: String, /// Replication mode: "full" mirrors every reachable object; /// "release" mirrors only release objects, their trees and blobs, /// and the authority chain (skipping inter-release commits) per §4.3. #[serde(default = "default_mirror_mode")] pub mode: String, /// Polling cadence as a duration string (e.g. "5m", "30s"). Used by /// the optional background poller; standalone `sync_mirror` calls do /// not consult this field. #[serde(default)] pub poll_interval: String, /// When true, this mirror accepts client pushes and forwards them to /// `source`. When false (the default), client pushes are rejected. /// §5.6 leaves the proxy mechanism implementation-defined; the wire /// behavior — read-only by default — is the part we must enforce. #[serde(default)] pub writeback: bool, } fn default_mirror_mode() -> String { "full".into() } impl InstanceConfig { /// Look up a mirror declaration for `repo_id`. Returns `None` for /// repositories the instance is authoritative for. pub fn mirror_for(&self, repo_id: &str) -> Option<&MirrorConfig> { self.mirrors.iter().find(|m| m.repo_id == repo_id) } /// Resolve the storage mode (§4.3). Empty / unset / "full" all /// mean full replication; the spec only enumerates three valid /// values, so anything else is treated as full and warned about /// at instance startup. Used by the push handler to gate which /// reference namespaces accept updates. pub fn storage_mode(&self) -> StorageMode { match self.storage_mode.as_str() { "release" => StorageMode::Release, "metadata" => StorageMode::Metadata, _ => StorageMode::Full, } } } /// One of the three modes from §4.3. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum StorageMode { /// Full replication — accepts every push. Full, /// Releases + their trees + reachable blobs + authority chain. /// Rejects pushes that update branches; only `refs/releases/*` /// updates are accepted. Release, /// Authority objects, release headers, signed references only. /// Rejects all pushes — metadata-mode instances are typically /// populated by mirroring rather than direct push. Metadata, } #[derive(Clone)] pub struct AppState { pub config: Arc, pub nonce_cache: Arc>, pub repo_locks: Arc>>>>, } impl AppState { pub fn new(config: InstanceConfig) -> Self { Self { config: Arc::new(config), nonce_cache: Arc::new(Mutex::new(NonceCache::default())), repo_locks: Arc::new(RwLock::new(HashMap::new())), } } pub fn repo_dir(&self, repo_id: &str) -> PathBuf { self.config.root.join(repo_id) } pub fn store(&self, repo_id: &str) -> ObjectStore { ObjectStore::new(self.repo_dir(repo_id).join(".levcs/objects")) } } /// Replay-protection cache for §5.3 request nonces. /// /// `verify_request` already rejects timestamps outside ±`DEFAULT_CLOCK_SKEW`, /// so a nonce only needs to be remembered while its parent timestamp is /// still within the skew window — anything older is rejected for skew /// before the cache is even consulted. We use `NONCE_TTL_SECS` (the /// protocol-level constant) as the retention horizon, which is wider than /// the skew window so that a small clock difference between client and /// server can't open a replay window between the two checks. /// /// The earlier implementation was a `HashSet` that called `clear()` once /// it grew past a count cap. That was a real replay vulnerability: an /// attacker who captured a recent signed request could replay it the /// instant the cache wiped, regardless of how long the original was /// supposed to remain "seen." The TTL approach below is bounded in /// memory by the rate of accepted requests times the TTL — at typical /// federation load that's a few thousand entries, kilobytes of state. /// How many inserts to accept before sweeping expired entries. Eviction /// is O(len), so amortizing keeps the per-call cost O(1) average. Stale /// entries that sit in the map a little longer cost nothing — they /// would just match the TTL skew check upstream and be rejected anyway. const NONCE_EVICT_BATCH: usize = 1024; #[derive(Default)] pub struct NonceCache { /// `nonce → request timestamp (micros since epoch)`. We index by /// timestamp rather than insertion time so a delayed request whose /// own clock is slightly behind ours can't sneak past TTL eviction. seen: HashMap<[u8; 16], i64>, inserts_since_evict: usize, } impl NonceCache { /// Check whether `nonce` (carried with `request_ts_micros`) has been /// seen, and if not, record it. `now_micros` is the verifier's notion /// of the current time, used to evict stale entries periodically. /// Returns `true` if the nonce was *new* (request should proceed), /// `false` if it was a replay. pub fn check_and_insert( &mut self, nonce: [u8; 16], request_ts_micros: i64, now_micros: i64, ) -> bool { self.inserts_since_evict += 1; if self.inserts_since_evict >= NONCE_EVICT_BATCH { let cutoff = now_micros - NONCE_TTL_SECS * 1_000_000; self.seen.retain(|_, ts| *ts >= cutoff); self.inserts_since_evict = 0; } if self.seen.contains_key(&nonce) { return false; } self.seen.insert(nonce, request_ts_micros); true } #[cfg(test)] pub fn len(&self) -> usize { self.seen.len() } } pub fn router(state: AppState) -> Router { use tower_http::trace::TraceLayer; Router::new() // Operational endpoint — outside /levcs/v1 so reverse proxies // can probe liveness without touching the federation surface. // Cheap on purpose: doesn't read state, doesn't touch disk. .route("/health", get(handle_health)) .route("/levcs/v1/instance/info", get(handle_instance_info)) .route("/levcs/v1/instance/peers", get(handle_instance_peers)) .route("/levcs/v1/repos/:repo_id/info", get(handle_repo_info)) .route("/levcs/v1/repos/:repo_id/refs", get(handle_repo_refs)) .route( "/levcs/v1/repos/:repo_id/objects/:hash", get(handle_get_object), ) .route("/levcs/v1/repos/:repo_id/pack", get(handle_get_pack)) .route("/levcs/v1/repos/:repo_id/push", post(handle_push)) .route("/levcs/v1/repos/:repo_id/init", post(handle_init)) .layer(TraceLayer::new_for_http()) .with_state(state) } async fn handle_health() -> impl IntoResponse { axum::Json(serde_json::json!({"status": "ok"})) } #[derive(Debug)] struct ApiError(StatusCode, String); impl IntoResponse for ApiError { fn into_response(self) -> Response { // Surface every error response in the server log before sending it // to the client. Without this, 5xx and auth failures would vanish // — the client sees the body but nothing reaches the operator. // 5xx is a server-side bug worth `error!`; 4xx is the caller's // problem (bad signature, malformed pack, conflict) and lands at // `warn!` so it's still grep-able but doesn't blow up alerts. let level_5xx = self.0.is_server_error(); if level_5xx { tracing::error!(status = %self.0, error = %self.1, "request failed"); } else { tracing::warn!(status = %self.0, error = %self.1, "request rejected"); } (self.0, self.1).into_response() } } fn err(status: StatusCode, msg: impl Into) -> ApiError { ApiError(status, msg.into()) } async fn handle_instance_info(State(s): State) -> impl IntoResponse { let info = InstanceInfo { software: "levcs-instance".into(), version: env!("CARGO_PKG_VERSION").into(), storage_mode: if s.config.storage_mode.is_empty() { "full".into() } else { s.config.storage_mode.clone() }, allowed_handlers: s.config.allowed_handlers.clone(), federation_peers: s.config.federation_peers.clone(), }; axum::Json(info) } async fn handle_instance_peers(State(s): State) -> impl IntoResponse { axum::Json(s.config.federation_peers.clone()) } async fn handle_repo_info( State(s): State, Path(repo_id): Path, ) -> Result, ApiError> { let dir = s.repo_dir(&repo_id); if !dir.is_dir() { return Err(err(StatusCode::NOT_FOUND, "repo not found")); } let refs = levcs_core::Refs::new(dir.join(".levcs")); let cur = refs .read("refs/authority/current") .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let genesis = refs .read("refs/authority/genesis") .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let branches = refs .list_branches() .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let mirror = s.config.mirror_for(&repo_id); let mut info = InfoResponse { repo_id, current_authority: cur.map(|c| c.to_hex()).unwrap_or_default(), genesis_authority: genesis.map(|c| c.to_hex()).unwrap_or_default(), is_mirror: mirror.is_some(), mirror_source: mirror.map(|m| m.source.clone()), mirror_mode: mirror.map(|m| m.mode.clone()), ..Default::default() }; for (k, v) in branches { info.branches.insert(k, v.to_hex()); } // Releases also belong in /info — clients without a mirror config look // here to discover the latest release for `construct --release` etc. let releases_dir = dir.join(".levcs/refs/releases"); if releases_dir.is_dir() { if let Ok(read) = std::fs::read_dir(&releases_dir) { for ent in read.flatten() { let name = ent.file_name().to_string_lossy().to_string(); if let Ok(txt) = std::fs::read_to_string(ent.path()) { info.releases.insert(name, txt.trim().to_string()); } } } } Ok(axum::Json(info)) } async fn handle_repo_refs( State(s): State, Path(repo_id): Path, ) -> Result, ApiError> { let dir = s.repo_dir(&repo_id); if !dir.is_dir() { return Err(err(StatusCode::NOT_FOUND, "repo not found")); } let refs = levcs_core::Refs::new(dir.join(".levcs")); let mut out = RefList::default(); for (k, v) in refs .list_branches() .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? { out.branches.insert(k, v.to_hex()); } let releases_dir = dir.join(".levcs/refs/releases"); if releases_dir.is_dir() { if let Ok(read) = std::fs::read_dir(&releases_dir) { for ent in read.flatten() { let name = ent.file_name().to_string_lossy().to_string(); if let Ok(txt) = std::fs::read_to_string(ent.path()) { out.releases.insert(name, txt.trim().to_string()); } } } } Ok(axum::Json(out)) } async fn handle_get_object( State(s): State, Path((repo_id, hash)): Path<(String, String)>, ) -> Result, ApiError> { let id = ObjectId::from_hex(&hash).map_err(|e| err(StatusCode::BAD_REQUEST, e.to_string()))?; let store = s.store(&repo_id); let bytes = store .read_raw(id) .map_err(|e| err(StatusCode::NOT_FOUND, e.to_string()))?; Ok(bytes) } #[derive(Deserialize)] struct PackQuery { #[serde(default)] have: String, #[serde(default)] want: String, } async fn handle_get_pack( State(s): State, Path(repo_id): Path, Query(q): Query, ) -> Result, ApiError> { let store = s.store(&repo_id); let have: Vec = q .have .split(',') .filter(|x| !x.is_empty()) .map(ObjectId::from_hex) .collect::>() .map_err(|e| err(StatusCode::BAD_REQUEST, e.to_string()))?; let want: Vec = q .want .split(',') .filter(|x| !x.is_empty()) .map(ObjectId::from_hex) .collect::>() .map_err(|e| err(StatusCode::BAD_REQUEST, e.to_string()))?; // Compute closure of `want` minus closure of `have` (transitively). let mut have_closure: HashSet = HashSet::new(); for h in &have { collect_closure(&store, *h, &mut have_closure); } let mut want_set: HashSet = HashSet::new(); for w in &want { collect_closure(&store, *w, &mut want_set); } let mut pack = Pack::new(); for id in want_set.difference(&have_closure) { if let Ok(bytes) = store.read_raw(*id) { // Determine type by parsing header byte. if bytes.len() >= 5 { pack.push(bytes[4], bytes); } } } Ok(pack.encode()) } fn collect_closure(store: &ObjectStore, id: ObjectId, out: &mut HashSet) { if !out.insert(id) { return; } let raw = match store.read_object(id) { Ok(r) => r, Err(_) => return, }; use levcs_core::object::ObjectType; match raw.object_type { ObjectType::Tree => { if let Ok(tree) = levcs_core::Tree::parse_body(&raw.body) { for e in tree.entries { collect_closure(store, e.hash, out); } } } ObjectType::Commit => { if let Ok(commit) = levcs_core::Commit::parse_body(&raw.body) { collect_closure(store, commit.tree, out); collect_closure(store, commit.authority, out); for p in commit.parents { collect_closure(store, p, out); } } } ObjectType::Release => { if let Ok(rel) = levcs_core::Release::parse_body(&raw.body) { collect_closure(store, rel.tree, out); collect_closure(store, rel.predecessor, out); collect_closure(store, rel.authority, out); if !rel.parent_release.is_zero() { collect_closure(store, rel.parent_release, out); } } } ObjectType::Authority => { if let Ok(body) = AuthorityBody::parse(&raw.body) { if !body.previous_authority.is_zero() { collect_closure(store, body.previous_authority, out); } } } ObjectType::Blob => {} } } #[derive(Debug)] struct AuthCheck { pub key: PublicKey, } fn verify_request_against( s: &AppState, headers: &HeaderMap, method: &str, path: &str, body: &[u8], ) -> Result { let h = |name: &'static str| { headers .get(name) .and_then(|v| v.to_str().ok()) .ok_or_else(|| err(StatusCode::UNAUTHORIZED, format!("missing header {name}"))) }; let key = h("LeVCS-Key")?; let ts = h("LeVCS-Timestamp")?; let nonce = h("LeVCS-Nonce")?; let sig = h("LeVCS-Signature")?; let now = levcs_protocol::auth::current_micros(); let req = AuthRequest { method, path_with_query: path, body, }; let auth = verify_request(&req, key, ts, nonce, sig, now, DEFAULT_CLOCK_SKEW) .map_err(|e| err(StatusCode::UNAUTHORIZED, e.to_string()))?; let mut cache = s.nonce_cache.lock().unwrap(); if !cache.check_and_insert(auth.nonce, auth.timestamp_micros, now) { return Err(err(StatusCode::UNAUTHORIZED, "replayed nonce")); } Ok(AuthCheck { key: auth.key }) } async fn handle_init( State(s): State, Path(repo_id): Path, headers: HeaderMap, body: Bytes, ) -> Result { let path = format!("/repos/{repo_id}/init"); let auth = verify_request_against(&s, &headers, "POST", &path, body.as_ref())?; // Body is the genesis authority object (signed). use levcs_core::object::SignedObject; let signed = SignedObject::parse(&body).map_err(|e| err(StatusCode::BAD_REQUEST, e.to_string()))?; let body_parsed = verify_genesis(&signed).map_err(|e| err(StatusCode::BAD_REQUEST, e.to_string()))?; if hex::encode(body_parsed.repo_id.as_bytes()) != repo_id { return Err(err( StatusCode::BAD_REQUEST, "URL repo_id does not match authority body", )); } if body_parsed.find_member(&auth.key).is_none() { return Err(err( StatusCode::FORBIDDEN, "init key is not a member of the authority", )); } let dir = s.repo_dir(&repo_id); if dir.is_dir() { return Err(err(StatusCode::CONFLICT, "repo already exists")); } levcs_core::Repository::init_skeleton(&dir) .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let store = s.store(&repo_id); let bytes = signed.serialize(); let id = store .write_raw(&bytes) .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let refs = levcs_core::Refs::new(dir.join(".levcs")); refs.write("refs/authority/genesis", id) .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; refs.write("refs/authority/current", id) .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(StatusCode::CREATED) } async fn handle_push( State(s): State, Path(repo_id): Path, headers: HeaderMap, body: Bytes, ) -> Result { let path = format!("/repos/{repo_id}/push"); let auth = verify_request_against(&s, &headers, "POST", &path, body.as_ref())?; let dir = s.repo_dir(&repo_id); if !dir.is_dir() { return Err(err(StatusCode::NOT_FOUND, "repo not found")); } // §5.6: a mirror is read-only by default. Reject pushes unless the // operator has explicitly opted into writeback. We return 403 with a // body that points clients at the source so they can retry there. if let Some(m) = s.config.mirror_for(&repo_id) { if !m.writeback { return Err(err( StatusCode::FORBIDDEN, format!( "this instance mirrors {repo_id} from {} and does not accept writes; push to the source instead", m.source ), )); } } // §4.3 storage-mode enforcement. We need the parsed manifest to // gate by ref namespace, so the actual rejection happens after // the manifest is decoded below. Metadata-mode is the only // wholesale reject we can make right now; the others require // looking at `manifest.updates`. if s.config.storage_mode() == StorageMode::Metadata { return Err(err( StatusCode::FORBIDDEN, "instance is in metadata-only mode and does not accept pushes; \ populate via mirror configuration", )); } // Body layout: pack || u32 manifest_len || manifest_json || 64 sig if body.len() < 4 + 64 { return Err(err(StatusCode::BAD_REQUEST, "body too short")); } let (pack, pack_len) = Pack::decode_prefix(&body) .map_err(|e| err(StatusCode::BAD_REQUEST, format!("pack decode: {e}")))?; if body.len() < pack_len + 4 + 64 { return Err(err(StatusCode::BAD_REQUEST, "body truncated after pack")); } let manifest_len = u32::from_le_bytes([ body[pack_len], body[pack_len + 1], body[pack_len + 2], body[pack_len + 3], ]) as usize; if body.len() != pack_len + 4 + manifest_len + 64 { return Err(err(StatusCode::BAD_REQUEST, "body length mismatch")); } let manifest_json = &body[pack_len + 4..pack_len + 4 + manifest_len]; let manifest_sig = &body[pack_len + 4 + manifest_len..]; let mut sig_arr = [0u8; 64]; sig_arr.copy_from_slice(manifest_sig); auth.key .verify(manifest_json, &sig_arr) .map_err(|_| err(StatusCode::UNAUTHORIZED, "manifest signature invalid"))?; let manifest: levcs_protocol::PushManifest = serde_json::from_slice(manifest_json) .map_err(|e| err(StatusCode::BAD_REQUEST, e.to_string()))?; // §4.3: release-mode instances accept only release ref updates. // Inter-release commits are not stored — `refs/branches/*` updates // would require us to keep the commit chain. Reject early so the // client gets a clear message before any object lands in the store. if s.config.storage_mode() == StorageMode::Release { for u in &manifest.updates { if !u.r#ref.starts_with("refs/releases/") { return Err(err( StatusCode::FORBIDDEN, format!( "instance is in release-only mode; ref {:?} is not a release \ (only refs/releases/* updates are accepted)", u.r#ref ), )); } } } let store = s.store(&repo_id); // Acquire per-repo lock for atomic ref updates. let lock = { let mut map = s.repo_locks.write().unwrap(); map.entry(repo_id.clone()) .or_insert_with(|| Arc::new(Mutex::new(()))) .clone() }; let _guard = lock.lock().unwrap(); // Step 1: write all pack objects to the loose store (validated framing). for ent in &pack.entries { store .write_raw(&ent.bytes) .map_err(|e| err(StatusCode::BAD_REQUEST, e.to_string()))?; } // Step 1b: enforce instance merge-policy (§6.6.4). Walk every Commit in // the pack; if its tree carries `.levcs/merge-record`, parse it and // reject the whole push if any handler reference falls outside // `allowed_handlers`. The repository's own policy is the inner // constraint and is verified independently elsewhere; this is the // outer ceiling. if !s.config.allowed_handlers.is_empty() { for ent in &pack.entries { if ent.object_type != ObjectType::Commit as u8 { continue; } let signed = match levcs_core::object::SignedObject::parse(&ent.bytes) { Ok(s) => s, Err(_) => continue, }; let commit = match Commit::from_signed(&signed) { Ok(c) => c, Err(_) => continue, }; let record_bytes = match find_merge_record(&store, commit.tree) { Ok(Some(b)) => b, _ => continue, }; let record_str = match std::str::from_utf8(&record_bytes) { Ok(s) => s, Err(_) => { return Err(err( StatusCode::BAD_REQUEST, "merge-record blob is not valid UTF-8", )); } }; let record = MergeRecord::from_toml(record_str) .map_err(|e| err(StatusCode::BAD_REQUEST, format!("merge-record: {e}")))?; for fr in &record.files { if !check_handler_allowed(&fr.handler, &fr.handler_hash, &s.config.allowed_handlers) { return Err(err( StatusCode::FORBIDDEN, format!( "merge handler '{}' is not permitted by this instance's policy", fr.handler ), )); } } } } // Step 2: verify authority chain on the manifest's authority_hash. let auth_hash = ObjectId::from_hex(&manifest.authority_hash) .map_err(|e| err(StatusCode::BAD_REQUEST, e.to_string()))?; verify_authority_chain(&store, auth_hash) .map_err(|e| err(StatusCode::BAD_REQUEST, format!("authority chain: {e}")))?; let auth_obj = store .read_raw(auth_hash) .map_err(|e| err(StatusCode::BAD_REQUEST, e.to_string()))?; let auth_signed = levcs_core::object::SignedObject::parse(&auth_obj) .map_err(|e| err(StatusCode::BAD_REQUEST, e.to_string()))?; let auth_body = AuthorityBody::parse(&auth_signed.body) .map_err(|e| err(StatusCode::BAD_REQUEST, e.to_string()))?; // Step 3: verify pusher has appropriate role. let member = auth_body .find_member(&auth.key) .ok_or_else(|| err(StatusCode::FORBIDDEN, "pusher not in authority"))?; if member.role < levcs_identity::authority::Role::Contributor { return Err(err(StatusCode::FORBIDDEN, "pusher lacks contributor role")); } // Step 4: verify each new commit and compare-and-swap each ref. let refs = levcs_core::Refs::new(dir.join(".levcs")); for u in &manifest.updates { let new_id = ObjectId::from_hex(&u.new_hash) .map_err(|e| err(StatusCode::BAD_REQUEST, e.to_string()))?; let old_actual = refs .read(&u.r#ref) .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let old_expected = match &u.old_hash { Some(s) if !s.is_empty() => Some( ObjectId::from_hex(s) .map_err(|e| err(StatusCode::BAD_REQUEST, format!("bad old_hash: {e}")))?, ), _ => None, }; if old_actual != old_expected { return Err(err( StatusCode::CONFLICT, format!("ref {} changed concurrently", u.r#ref), )); } // Dispatch verification by the new tip's object type so we can // accept both branch refs (commit-typed) and release refs // (release-typed). Anything else gets rejected up front. let raw = store .read_object(new_id) .map_err(|e| err(StatusCode::BAD_REQUEST, e.to_string()))?; match raw.object_type { ObjectType::Commit => { levcs_identity::verify::verify_commit(&store, new_id, Some(&u.r#ref)) .map_err(|e| err(StatusCode::BAD_REQUEST, format!("commit verify: {e}")))?; } ObjectType::Release => { levcs_identity::verify::verify_release(&store, new_id) .map_err(|e| err(StatusCode::BAD_REQUEST, format!("release verify: {e}")))?; } other => { return Err(err( StatusCode::BAD_REQUEST, format!( "ref tip is {} object, must be Commit or Release", other.name() ), )); } } // §5.4(e): non-fast-forward updates require force-push and a // sufficiently privileged key. Only check when this is an // update of an existing ref (old_actual is Some) — first-write // refs have no ancestry constraint. if let Some(old_id) = old_actual { if !is_ancestor(&store, old_id, new_id) { if !manifest.force { return Err(err( StatusCode::CONFLICT, format!( "non-fast-forward update for ref {}; pass --force to override", u.r#ref ), )); } if member.role < levcs_identity::authority::Role::Maintainer { return Err(err( StatusCode::FORBIDDEN, format!( "force-push to {} requires maintainer or owner role", u.r#ref ), )); } } } refs.write(&u.r#ref, new_id) .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; } // Update current authority pointer if any pushed tip modified it. // Walk all updates rather than just the last so the order in `updates` // can be arbitrary; pick the highest-numbered authority by version. let mut best_auth: Option<(ObjectId, u32)> = None; for u in &manifest.updates { let id = match ObjectId::from_hex(&u.new_hash) { Ok(i) => i, Err(_) => continue, }; let bytes = match store.read_raw(id) { Ok(b) => b, Err(_) => continue, }; let signed = match levcs_core::object::SignedObject::parse(&bytes) { Ok(s) => s, Err(_) => continue, }; let auth_id = match signed.object_type { ObjectType::Commit => match levcs_core::Commit::from_signed(&signed) { Ok(c) => c.authority, Err(_) => continue, }, ObjectType::Release => match levcs_core::Release::parse_body(&signed.body) { Ok(r) => r.authority, Err(_) => continue, }, _ => continue, }; let auth_bytes = match store.read_raw(auth_id) { Ok(b) => b, Err(_) => continue, }; let auth_signed = match levcs_core::object::SignedObject::parse(&auth_bytes) { Ok(s) => s, Err(_) => continue, }; let auth_body = match AuthorityBody::parse(&auth_signed.body) { Ok(b) => b, Err(_) => continue, }; match best_auth { None => best_auth = Some((auth_id, auth_body.version)), Some((_, v)) if auth_body.version > v => best_auth = Some((auth_id, auth_body.version)), _ => {} } } if let Some((auth_id, _)) = best_auth { refs.write("refs/authority/current", auth_id) .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; } Ok(StatusCode::OK) } /// Helper used by tests and the binary's `main` to bind and serve. pub async fn serve(state: AppState, addr: std::net::SocketAddr) -> std::io::Result<()> { let app = router(state); let listener = tokio::net::TcpListener::bind(addr).await?; axum::serve(listener, app).await } /// Decide whether `old_id` is an ancestor of `new_id` for fast-forward /// detection on push (§5.4(e)). Walks parent chains starting from /// `new_id`. Considers Commit parents and Release predecessor + /// parent_release; both flavours of ref can be advanced and either /// chain might lead back to `old_id`. Returns false on read errors — /// safer to require force-push than to silently accept an update we /// can't verify. fn is_ancestor(store: &ObjectStore, old_id: ObjectId, new_id: ObjectId) -> bool { use levcs_core::Release; if old_id == new_id { return true; } let mut visited: HashSet = HashSet::new(); let mut stack = vec![new_id]; while let Some(id) = stack.pop() { if !visited.insert(id) { continue; } if id == old_id { return true; } let raw = match store.read_object(id) { Ok(r) => r, Err(_) => continue, }; match raw.object_type { ObjectType::Commit => { if let Ok(c) = Commit::parse_body(&raw.body) { stack.extend(c.parents); } } ObjectType::Release => { if let Ok(r) = Release::parse_body(&raw.body) { if !r.predecessor.is_zero() { stack.push(r.predecessor); } if !r.parent_release.is_zero() { stack.push(r.parent_release); } } } _ => {} } } false } /// Walk into `tree_id` looking for `.levcs/merge-record` and return the blob /// body if found. Returns `Ok(None)` for a tree with no `.levcs` subtree, no /// `merge-record` entry, or any non-blob entry at that path. fn find_merge_record( store: &ObjectStore, tree_id: ObjectId, ) -> Result>, levcs_core::error::Error> { if tree_id.is_zero() { return Ok(None); } let raw = store.read_typed(tree_id, ObjectType::Tree)?; let tree = Tree::parse_body(&raw.body)?; let levcs_entry = match tree.entries.iter().find(|e| e.name == ".levcs") { Some(e) if e.entry_type == EntryType::Tree => e, _ => return Ok(None), }; let raw = store.read_typed(levcs_entry.hash, ObjectType::Tree)?; let levcs_tree = Tree::parse_body(&raw.body)?; let mr_entry = match levcs_tree.entries.iter().find(|e| e.name == "merge-record") { Some(e) if e.entry_type == EntryType::Blob => e, _ => return Ok(None), }; let blob = store.read_typed(mr_entry.hash, ObjectType::Blob)?; Ok(Some(blob.body)) } // Allow `verify_authority_chain` to use ObjectStore directly. #[allow(dead_code)] fn _vs(_: &dyn VerifySource) {} #[cfg(test)] mod tests { use super::*; fn micros_from_secs(s: i64) -> i64 { s * 1_000_000 } /// Re-inserting the same nonce within the TTL window must be rejected. /// This is the core anti-replay invariant; before the TTL rewrite the /// cache also satisfied this property, so a green test here is the /// floor, not the ceiling. #[test] fn nonce_replay_within_ttl_is_rejected() { let mut cache = NonceCache::default(); let nonce = [0x42u8; 16]; let ts = micros_from_secs(1_700_000_000); let now = ts + micros_from_secs(1); assert!(cache.check_and_insert(nonce, ts, now)); // Same nonce, slightly later "now": still within TTL, must reject. assert!(!cache.check_and_insert(nonce, ts, now + micros_from_secs(60))); } /// Once a nonce ages past `NONCE_TTL_SECS` it must be evicted from /// the cache; what bounds memory growth is precisely this release. /// (`verify_request` will reject the timestamp for skew long before /// the cache ever sees a stale request again, so re-accepting the /// nonce bytes is safe.) /// /// We drive `NONCE_EVICT_BATCH` distinct inserts at a fresh timestamp /// to trigger one full eviction pass, then assert the original /// (now-stale) entry has been swept. #[test] fn nonce_evicted_after_ttl_expires() { let mut cache = NonceCache::default(); let stale = [0x42u8; 16]; let stale_ts = micros_from_secs(1_700_000_000); assert!(cache.check_and_insert(stale, stale_ts, stale_ts)); // Fast-forward "now" past the TTL window and force an eviction // sweep by inserting a batch of fresh nonces. let later = stale_ts + micros_from_secs(NONCE_TTL_SECS + 1); for i in 0..(NONCE_EVICT_BATCH as u32) { let mut n = [0u8; 16]; n[..4].copy_from_slice(&i.to_le_bytes()); n[15] = 0xFF; // disambiguate from `stale` assert!(cache.check_and_insert(n, later, later)); } // The stale entry is gone; same-nonce-bytes with a fresh // timestamp are allowed. assert!(cache.check_and_insert(stale, later, later)); } /// Regression test for the original CVE-shaped bug: the previous /// implementation called `seen.clear()` once it grew past 100k /// entries, which dropped every recently-seen nonce in one step and /// allowed any captured request still within the 5-minute clock-skew /// window to be replayed. /// /// Here we (a) drive the cache through several eviction passes with /// junk-but-fresh nonces, then (b) try to replay a still-fresh nonce /// inserted at the start. With time-bounded eviction the replay /// must be rejected, because the original entry's timestamp is still /// inside the TTL window. With the old count-bounded `clear()`, this /// test would erroneously succeed (the replay would be accepted). /// The flood size is intentionally a small multiple of /// `NONCE_EVICT_BATCH` — the property doesn't depend on the exact /// count, just on triggering the eviction path. #[test] fn nonce_cache_does_not_drop_fresh_entries_under_load() { let mut cache = NonceCache::default(); let base_ts = micros_from_secs(1_700_000_000); let mut victim = [0u8; 16]; victim[..8].copy_from_slice(&u64::MAX.to_le_bytes()); // Insert the "captured" request first. assert!(cache.check_and_insert(victim, base_ts, base_ts)); // Flood with NONCE_EVICT_BATCH * 4 fresh-but-distinct nonces, all // dated within the same TTL window so eviction can't help us. let flood: u32 = (NONCE_EVICT_BATCH as u32) * 4; for i in 0..flood { let mut n = [0u8; 16]; n[..4].copy_from_slice(&i.to_le_bytes()); let now = base_ts + (i as i64) * 1_000; assert!(cache.check_and_insert(n, now, now)); } let replay_now = base_ts + micros_from_secs(60); assert!( !cache.check_and_insert(victim, base_ts, replay_now), "replay of fresh nonce must be rejected even when cache is large" ); } }