77 lines
3.2 KiB
Rust
77 lines
3.2 KiB
Rust
//! 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` and `attachments/<slug>/<item-id>/<att-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());
|
|
}
|
|
// 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: <slug>/<item-id>/<att-id>.enc → three segments.
|
|
let segments: Vec<&str> = rest.split('/').collect();
|
|
if segments.len() != 3 {
|
|
return PathClass::Rejected(
|
|
"attachments path must be attachments/<slug>/<item-id>/<att-id>.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<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())
|
|
}
|