feat(server): lib target + pure org-hook helpers (classify_path, extract_schema_version) + unit tests
This commit is contained in:
@@ -5,6 +5,14 @@ edition = "2021"
|
|||||||
description = "Pre-receive Git hook for relicario password manager"
|
description = "Pre-receive Git hook for relicario password manager"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "relicario_server"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "relicario-server"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
relicario-core = { path = "../relicario-core" }
|
relicario-core = { path = "../relicario-core" }
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|||||||
51
crates/relicario-server/src/lib.rs
Normal file
51
crates/relicario-server/src/lib.rs
Normal 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())
|
||||||
|
}
|
||||||
71
crates/relicario-server/tests/org_hook.rs
Normal file
71
crates/relicario-server/tests/org_hook.rs
Normal 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());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user