From 675b7836e1ecfd99d98992949d2e4d6313040c5b Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 19 Jun 2026 20:43:25 -0400 Subject: [PATCH] feat(server): lib target + pure org-hook helpers (classify_path, extract_schema_version) + unit tests --- crates/relicario-server/Cargo.toml | 8 +++ crates/relicario-server/src/lib.rs | 51 ++++++++++++++++ crates/relicario-server/tests/org_hook.rs | 71 +++++++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 crates/relicario-server/src/lib.rs create mode 100644 crates/relicario-server/tests/org_hook.rs diff --git a/crates/relicario-server/Cargo.toml b/crates/relicario-server/Cargo.toml index 282d364..97d449b 100644 --- a/crates/relicario-server/Cargo.toml +++ b/crates/relicario-server/Cargo.toml @@ -5,6 +5,14 @@ edition = "2021" description = "Pre-receive Git hook for relicario password manager" license = "GPL-3.0-or-later" +[lib] +name = "relicario_server" +path = "src/lib.rs" + +[[bin]] +name = "relicario-server" +path = "src/main.rs" + [dependencies] relicario-core = { path = "../relicario-core" } anyhow = "1" diff --git a/crates/relicario-server/src/lib.rs b/crates/relicario-server/src/lib.rs new file mode 100644 index 0000000..d2caa0b --- /dev/null +++ b/crates/relicario-server/src/lib.rs @@ -0,0 +1,51 @@ +//! 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` — 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()); + } + 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()) +} diff --git a/crates/relicario-server/tests/org_hook.rs b/crates/relicario-server/tests/org_hook.rs new file mode 100644 index 0000000..5b6ebf0 --- /dev/null +++ b/crates/relicario-server/tests/org_hook.rs @@ -0,0 +1,71 @@ +// Integration tests for relicario-server org-hook path classification. + +use relicario_server::{classify_path, PathClass}; + +#[test] +fn protected_files_are_classified_protected() { + assert_eq!(classify_path("members.json"), PathClass::Protected); + assert_eq!(classify_path("collections.json"), PathClass::Protected); + assert_eq!(classify_path("org.json"), PathClass::Protected); +} + +#[test] +fn item_write_yields_collection_slug() { + assert_eq!( + classify_path("items/prod/a1b2c3d4e5f6a1b2.enc"), + PathClass::Item { collection: "prod".to_string() } + ); +} + +#[test] +fn item_write_nested_slug_takes_leading_segment_only() { + // Slugs cannot contain '/', so a 4-segment path is malformed → Rejected. + assert_eq!( + classify_path("items/prod/sub/x.enc"), + PathClass::Rejected("items path must be items//.enc".to_string()) + ); +} + +#[test] +fn key_blobs_and_manifest_are_unrestricted() { + // keys/.enc and manifest.enc are written by org operations; the SIGNATURE + // check (every commit must be signed by a current member) is the gate for them. + assert_eq!(classify_path("keys/a1b2c3d4e5f6a1b2.enc"), PathClass::Unrestricted); + assert_eq!(classify_path("manifest.enc"), PathClass::Unrestricted); +} + +#[test] +fn items_without_slug_segment_are_rejected() { + // Flat items/.enc (the OLD, now-removed layout) is no longer valid. + assert_eq!( + classify_path("items/a1b2c3d4e5f6a1b2.enc"), + PathClass::Rejected("items path must be items//.enc".to_string()) + ); +} + +#[test] +fn empty_slug_segment_is_rejected() { + assert_eq!( + classify_path("items//x.enc"), + PathClass::Rejected("empty collection slug in items path".to_string()) + ); +} + +use relicario_server::extract_schema_version; + +#[test] +fn extract_schema_version_reads_field() { + let json = r#"{ "schema_version": 3, "members": [] }"#; + assert_eq!(extract_schema_version(json).unwrap(), 3); +} + +#[test] +fn extract_schema_version_errors_on_missing_field() { + let json = r#"{ "members": [] }"#; + assert!(extract_schema_version(json).is_err()); +} + +#[test] +fn extract_schema_version_errors_on_garbage() { + assert!(extract_schema_version("not json").is_err()); +}