//! Library surface for relicario-server, exposing pure helpers used by the //! pre-receive hooks so they can be unit-tested. /// Classification of a single changed path inside an org repo. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PathClass { /// `members.json`, `collections.json`, `org.json` — only Owner/Admin may write. Protected, /// `items//.enc` and `attachments///.enc` — /// writer must hold a grant for ``. Item { collection: String }, /// `keys/.enc`, `manifest.enc`, `.gitignore`, etc. — gated only by the /// per-commit signature check (signer must be a current member). Unrestricted, /// Structurally invalid path; commit must be rejected. Rejected(String), } /// Classify a repo-relative path. Pure; no I/O. pub fn classify_path(path: &str) -> PathClass { match path { "members.json" | "collections.json" | "org.json" => return PathClass::Protected, _ => {} } if let Some(rest) = path.strip_prefix("items/") { // Expect exactly: /.enc → two segments after the prefix. let segments: Vec<&str> = rest.split('/').collect(); if segments.len() != 2 { return PathClass::Rejected("items path must be items//.enc".to_string()); } let slug = segments[0]; if slug.is_empty() { return PathClass::Rejected("empty collection slug in items path".to_string()); } // Defense-in-depth: mirror `OrgCollections::validate` — a slug containing // '.' (e.g. a `..`/`.` path-traversal attempt) is structurally invalid. // git normalizes most `./` away before the hook sees the path, so this is // unreachable today; it keeps the hook self-defensive regardless. if slug.contains('.') { return PathClass::Rejected(format!("invalid collection slug: {:?}", slug)); } return PathClass::Item { collection: slug.to_string() }; } if let Some(rest) = path.strip_prefix("attachments/") { // Expect exactly: //.enc → three segments. let segments: Vec<&str> = rest.split('/').collect(); if segments.len() != 3 { return PathClass::Rejected( "attachments path must be attachments///.enc".to_string()); } let slug = segments[0]; if slug.is_empty() { return PathClass::Rejected("empty collection slug in attachments path".to_string()); } if slug.contains('.') { return PathClass::Rejected(format!("invalid collection slug: {:?}", slug)); } return PathClass::Item { collection: slug.to_string() }; } PathClass::Unrestricted } /// Extract the `schema_version` field from any org JSON document. /// Returns an error if the field is absent or not a u32. pub fn extract_schema_version(json: &str) -> Result { let value: serde_json::Value = serde_json::from_str(json).map_err(|e| format!("parse json: {e}"))?; value .get("schema_version") .and_then(|v| v.as_u64()) .map(|n| n as u32) .ok_or_else(|| "missing or non-integer schema_version".to_string()) }