git-ents.gitmain
⌘K
foforge
commit 5a3c2ca
feat: add a collection-key validation seam and a map-document helper to git-store

Every collection key becomes a ref path segment or tree entry name, but nothing checked it was safe as one before this. ref_segment_ok is the one place that is now checked, enforced by store_item/store_keyed before any write.

load_map/store_map add the other missing piece: the scalar-keyed-map document every named-entries-on-one-ref collection needs (checks, revocations, run outcomes), so a caller no longer hand-writes its own wrapper struct and key/body conversion.

feat: add Store::load_map/store_map for scalar-keyed map documents feat: reject an unsafe collection key with Error::InvalidKey feat: add Error::Invalid for a domain invariant a document itself enforces Assisted-by: Claude:claude-sonnet-4-6

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/git-store/src/lib.rs @@ -19,6 +19,7 @@ //! rather than in production. use std::cmp::Reverse; +use std::collections::BTreeMap; use std::path::Path; use facet::Facet; @@ -58,6 +59,38 @@ /// structural merge found the same leaf changed on both sides. #[error("conflicting concurrent write to the ref")] Conflict, + /// A collection key (an id passed to [`Store::store_item`], a value's + /// [`HasId::id`], or a map key given to [`Store::store_map`]) is unsafe + /// as a ref path segment or tree entry name. + #[error( + "{0:?} is not a valid collection key: expected 1-64 ASCII alphanumerics, '.', '_', '-', or ':', not starting with '.'" + )] + InvalidKey(String), + /// A document failed a domain invariant checked before the write (e.g. a + /// member's validity window is inverted). Distinct from + /// [`Error::InvalidKey`], which is about the collection key's ref-safety + /// rather than the document's own content. + #[error("{0}")] + Invalid(String), +} + +/// Whether `segment` is safe as a single ref-path or tree-entry segment: one +/// to sixty-four ASCII alphanumerics, `.`, `_`, `-`, or `:`, not starting with +/// `.`, and never containing `/`. Every collection key (a member's principal, +/// a check's name, a revoked key's colon-form MD5 fingerprint, an issue's +/// genesis hash) is stored as exactly one such segment, so an unchecked key +/// could otherwise inject an extra path component or collide with a sibling +/// entry. `:` is allowed — narrower than a repository path segment +/// (`namespace.path`) — because the MD5 fingerprint form the specification +/// requires (`cli.key-resolution`) is colon-separated. +#[must_use] +pub fn ref_segment_ok(segment: &str) -> bool { + !segment.is_empty() + && segment.len() <= 64 + && !segment.starts_with('.') + && segment + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b':')) } /// A document that legitimately stores its own collection key. @@ -214,7 +247,8 @@ /// Store `value` as item `id` under the collection ref namespace `prefix` /// (`{prefix}/{id}`), inheriting [`store`](Self::store)'s CAS-and-merge - /// behavior. + /// behavior. Rejects `id` per [`ref_segment_ok`] before writing anything, + /// since it becomes the ref's last path segment. pub fn store_item<T: for<'a> Facet<'a>>( &self, prefix: &str, @@ -222,6 +256,9 @@ value: &T, message: &str, ) -> Result<(), Error> { + if !ref_segment_ok(id) { + return Err(Error::InvalidKey(id.to_owned())); + } self.store(&format!("{prefix}/{id}"), value, message) } @@ -236,6 +273,46 @@ self.store_item(prefix, value.id(), value, message) } + /// Load the scalar-keyed map document on `refname` as its flattened + /// `Item` list, via `assemble` (the map key plus its `Body`, joined back + /// into the public item that names it once instead of storing it twice). + /// An absent ref yields an empty list. + /// + /// The one conversion every "named entries on a single ref" collection + /// (checks, revocations, run outcomes) needs, done once rather than by a + /// hand-written wrapper document per module. + pub fn load_map<Body: for<'a> Facet<'a>, Item>( + &self, + refname: &str, + assemble: impl Fn(String, Body) -> Item, + ) -> Result<Vec<Item>, Error> { + Ok(self + .load::<BTreeMap<String, Body>>(refname)? + .unwrap_or_default() + .into_iter() + .map(|(key, body)| assemble(key, body)) + .collect()) + } + + /// Replace the scalar-keyed map document on `refname` with `items`, via + /// `split` (an item back to its map key and `Body`). Rejects any key that + /// fails [`ref_segment_ok`] before writing anything. + pub fn store_map<Body: for<'a> Facet<'a>, Item>( + &self, + refname: &str, + items: &[Item], + split: impl Fn(&Item) -> (String, Body), + message: &str, + ) -> Result<(), Error> { + let doc: BTreeMap<String, Body> = items.iter().map(split).collect(); + for key in doc.keys() { + if !ref_segment_ok(key) { + return Err(Error::InvalidKey(key.clone())); + } + } + self.store(refname, &doc, message) + } + /// Every item under the collection ref namespace `prefix`, paired with the /// id (the ref's last path segment) it was stored under, newest first. pub fn list_items<T: for<'a> Facet<'a>>(