feat(server): lib target + pure org-hook helpers (classify_path, extract_schema_version) + unit tests

This commit is contained in:
adlee-was-taken
2026-06-19 20:43:25 -04:00
parent 743a46f3d5
commit 675b7836e1
3 changed files with 130 additions and 0 deletions

View File

@@ -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"

View File

@@ -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/<slug>/<id>.enc` — writer must hold a grant for `<slug>`.
Item { collection: String },
/// `keys/<id>.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: <slug>/<id>.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/<slug>/<id>.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<u32, String> {
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())
}

View File

@@ -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/<slug>/<id>.enc".to_string())
);
}
#[test]
fn key_blobs_and_manifest_are_unrestricted() {
// keys/<id>.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/<id>.enc (the OLD, now-removed layout) is no longer valid.
assert_eq!(
classify_path("items/a1b2c3d4e5f6a1b2.enc"),
PathClass::Rejected("items path must be items/<slug>/<id>.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());
}