server/lib/src/properties.rs
//! Custom **artifact properties** — user-defined key→value(s) metadata attached
//! to an artifact coordinate `(repository, namespace/name/version)`, and the
//! backing store the `property` search axis matches against (feature-complete-gaps
//! §8, the last deferred search axis).
//!
//! This is the Nexus/Artifactory "properties" capability: tag `(rust-prod,
//! serde 1.0.0)` with `env=prod`, `team=core`, `reviewed=yes`, then *find* every
//! artifact carrying `env=prod`. A key may hold **multiple values** (a set), so
//! `env=prod` and `env=staging` can coexist under `env`.
//!
//! **Storage.** A flat on-disk store rooted at `<data_root>/holger-props`, laid
//! out `holger-props/<repo>/<coord>.props.json`, each file a JSON object
//! `{ "<key>": ["<value>", …], … }`. The repo name and the coordinate are each
//! reduced to ONE path-safe segment by promote's [`sanitize_repo`] / [`coord_key`]
//! (reused, not twinned — Law #5), so an untrusted coordinate off the wire can
//! never write or read outside `root`. Writes are last-wins per key and staged
//! through a temp file + rename so a concurrent reader never sees a half-written
//! document. A missing coordinate reads as *no properties*, never an error.
//!
//! The store implements [`search::PropertyLookup`], so the pure search engine can
//! filter candidates by property without any knowledge of on-disk layout.
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::sync::Arc;
use traits::ArtifactId;
use crate::promote::{coord_key, is_own_storage_key, sanitize_repo};
/// A key→values property map for one artifact coordinate. `BTreeMap` so the
/// serialized JSON is deterministic (stable key order) — a re-write of the same
/// properties is byte-identical.
pub type PropertyMap = BTreeMap<String, Vec<String>>;
/// The on-disk store of custom artifact properties.
///
/// Layout under `root`: `<repo>/<coord>.props.json`. Both components are
/// sanitized to exactly one path segment, so a path is always a direct child of
/// `root/<repo>/` — traversal is structurally impossible.
pub struct PropertyStore {
root: PathBuf,
/// The repositories this server actually serves, or `None` for a store whose
/// caller has already resolved the repository itself (the CLI, in-process
/// use, tests). With a roster, a write naming a repository the server does
/// not serve is refused instead of creating a directory for it.
roster: Option<Arc<BTreeSet<String>>>,
}
/// Shared handle so every surface (gRPC state, HTTP door, CLI, in-process) reads
/// and writes the one store.
pub type SharedPropertyStore = Arc<PropertyStore>;
impl PropertyStore {
/// A store rooted at `root` (directories are created on first write). The
/// server roots it at `<data_root>/holger-props`; the CLI derives the same
/// path, so all surfaces share one store.
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
roster: None,
}
}
/// The same store, closed to `roster` — the set of repository names the
/// server serves. This is the constructor the SERVER uses: a door's caller
/// names the repository, so without a roster a property write invents a
/// namespace for a repository that does not exist, and nothing ever cleans
/// it up. See [`PropertyStore::storage_path`] for the rest of the rule.
pub fn with_roster(root: impl Into<PathBuf>, roster: Arc<BTreeSet<String>>) -> Self {
Self {
root: root.into(),
roster: Some(roster),
}
}
/// The path for `(repo, id)`, or `None` when this coordinate must not be
/// touched at all.
///
/// Three ways to get `None`, and all three are the same defect seen from
/// different sides: **the string a door authorized is not the string this
/// store would write under.**
///
/// 1. `repo` is not already its own storage key. `sanitize_repo` is
/// many-to-one, so `rust/prod` and `rust:prod` both land in `rust_prod`.
/// A door gates on the raw string — `role_for_repo("rust/prod")` matches
/// no per-repo rule and takes the permissive default, and the reserved-key
/// ADMIN floor is consulted for the same raw string — and then the bytes
/// land in the real `rust_prod`. That is a per-repo ACL bypass and a
/// quarantine-flag bypass in one move. The name is refused, never mapped.
/// 2. The coordinate is not its own storage key, for the same reason one
/// level down: two coordinates would otherwise share one document.
/// 3. `repo` is not a repository this server serves (roster mode).
///
/// Refusing rather than sanitizing is the whole point: a redirected write is
/// invisible, a refused one is not.
fn storage_path(&self, repo: &str, id: &ArtifactId) -> Option<PathBuf> {
if !is_own_storage_key(repo) {
return None;
}
if let Some(roster) = &self.roster {
if !roster.contains(repo) {
return None;
}
}
Some(
self.root
.join(sanitize_repo(repo))
.join(format!("{}.props.json", coord_key(id))),
)
}
/// All properties attached to `(repo, id)` — an empty map when none are set (a
/// missing or unreadable document reads as *no properties*, never an error, so
/// a search over a mixed store never sinks on one bad file).
/// A coordinate this store refuses (see [`PropertyStore::storage_path`])
/// reads as *no properties* rather than as the properties of whatever
/// coordinate it would have collapsed onto — a read must not leak across the
/// boundary a write is refused at.
pub fn get(&self, repo: &str, id: &ArtifactId) -> PropertyMap {
let Some(path) = self.storage_path(repo, id) else {
return PropertyMap::new();
};
match std::fs::read(path) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
Err(_) => PropertyMap::new(),
}
}
/// Set the values of `key` on `(repo, id)` (last-wins for that key, other keys
/// untouched). An **empty** `values` REMOVES the key. Values are de-duplicated
/// preserving order. Returns the resulting full property map. Atomic
/// (write-temp-then-rename) so a concurrent `get` never sees a partial doc.
pub fn set(
&self,
repo: &str,
id: &ArtifactId,
key: &str,
values: Vec<String>,
) -> std::io::Result<PropertyMap> {
let mut map = self.get(repo, id);
if values.is_empty() {
map.remove(key);
} else {
let mut deduped: Vec<String> = Vec::with_capacity(values.len());
for v in values {
if !deduped.contains(&v) {
deduped.push(v);
}
}
map.insert(key.to_string(), deduped);
}
self.write_map(repo, id, &map)?;
Ok(map)
}
/// Persist `map` for `(repo, id)`. When the map is empty the document is
/// removed (a coordinate with no properties leaves no file behind).
fn write_map(&self, repo: &str, id: &ArtifactId, map: &PropertyMap) -> std::io::Result<()> {
let Some(path) = self.storage_path(repo, id) else {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"refusing to store properties for repository {repo:?}: it is not a \
repository this server serves under exactly that name (a name that \
is not already path-safe would be stored under a DIFFERENT \
repository than the one authorized)"
),
));
};
if map.is_empty() {
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
} else {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let bytes = serde_json::to_vec(map).map_err(std::io::Error::other)?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, &bytes)?;
std::fs::rename(&tmp, &path)
}
}
/// Whether `(repo, id)` carries `key` (with any value when `value` is empty,
/// else with `value` present among its values — exact, case-sensitive). This
/// is the predicate the `property` search axis is built on.
pub fn matches(&self, repo: &str, id: &ArtifactId, key: &str, value: &str) -> bool {
map_matches(&self.get(repo, id), key, value)
}
}
/// Pure predicate: does the property `map` carry `key` (with any value when
/// `value` is empty, else with `value` present among its values — exact,
/// case-sensitive)? The single matching rule shared by the search axis,
/// [`PropertyStore::matches`], and the retention/promotion/quarantine property
/// gates — so a `keep=true` pin, a `signed=true` promotion requirement, and a
/// `quarantine=true` serve block all mean exactly the same thing (Law #5, no twin).
pub fn map_matches(map: &PropertyMap, key: &str, value: &str) -> bool {
match map.get(key) {
Some(values) => value.is_empty() || values.iter().any(|v| v == value),
None => false,
}
}
/// **Is `key` a property the SERVER ITSELF reads as policy, rather than a label
/// describing an artifact?** Such a key is ADMIN-only on every door.
///
/// Two sources, and they are different in kind, which is why this takes two
/// arguments instead of one set (the serve-time quarantine predicate was the
/// third until W6 retired it: a hold is the intake chain's QUARANTINED state):
///
/// * the promotion engine's `required_property` key — setting it walks an
/// unreviewed artifact into PROD;
/// * the configured [`crate::Holger::reserved_property_keys`] — by default the
/// retention pin `keep`, which lives in a CLI-built policy the server process
/// never sees, so its key is named in config instead of derived.
///
/// This lives here, in the module that owns the store, because BOTH write doors
/// must ask the same question. It was originally asked only by the gRPC
/// `SetProperty` handler, and the HTTP `/-/properties` door — writing the very
/// same store — did not ask it at all: a Writer refused at one door simply used
/// the other. A policy gate on one of two doors is not a gate.
pub fn is_reserved_property(
key: &str,
promotion_required: Option<&(String, String)>,
configured: &std::collections::BTreeSet<String>,
) -> bool {
if promotion_required.is_some_and(|(k, _)| k == key) {
return true;
}
configured.contains(key)
}
/// Parse a `key=value` (or bare `key`, = "any value") property predicate from a
/// config/env string. `key=value` ⇒ `(key, value)`; bare `key` ⇒ `(key, "")`;
/// empty, whitespace, or an empty key (`=value`) ⇒ `None`. The single predicate
/// parser of the promotion requirement (Law #5).
pub fn parse_predicate(raw: &str) -> Option<(String, String)> {
let raw = raw.trim();
if raw.is_empty() {
return None;
}
match raw.split_once('=') {
Some((k, v)) if !k.trim().is_empty() => Some((k.trim().to_string(), v.to_string())),
None => Some((raw.to_string(), String::new())),
Some(_) => None, // empty key (`=value`) is meaningless
}
}
impl crate::search::PropertyLookup for PropertyStore {
fn properties_of(&self, repo: &str, id: &ArtifactId) -> PropertyMap {
self.get(repo, id)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn id(ns: Option<&str>, name: &str, ver: &str) -> ArtifactId {
ArtifactId {
namespace: ns.map(str::to_string),
name: name.into(),
version: ver.into(),
}
}
#[test]
fn set_then_get_round_trip_multi_value() {
let tmp = tempfile::tempdir().unwrap();
let store = PropertyStore::new(tmp.path());
let coord = id(None, "serde", "1.0.0");
store
.set(
"rust-prod",
&coord,
"env",
vec!["prod".into(), "staging".into()],
)
.unwrap();
store
.set("rust-prod", &coord, "team", vec!["core".into()])
.unwrap();
let got = store.get("rust-prod", &coord);
assert_eq!(
got.get("env").unwrap(),
&vec!["prod".to_string(), "staging".to_string()]
);
assert_eq!(got.get("team").unwrap(), &vec!["core".to_string()]);
}
#[test]
fn set_dedups_values() {
let tmp = tempfile::tempdir().unwrap();
let store = PropertyStore::new(tmp.path());
let coord = id(None, "x", "1");
store
.set("r", &coord, "k", vec!["a".into(), "a".into(), "b".into()])
.unwrap();
assert_eq!(
store.get("r", &coord).get("k").unwrap(),
&vec!["a".to_string(), "b".to_string()]
);
}
#[test]
fn set_empty_values_removes_key() {
let tmp = tempfile::tempdir().unwrap();
let store = PropertyStore::new(tmp.path());
let coord = id(None, "x", "1");
store.set("r", &coord, "k", vec!["v".into()]).unwrap();
assert!(store.get("r", &coord).contains_key("k"));
store.set("r", &coord, "k", vec![]).unwrap(); // remove
assert!(
!store.get("r", &coord).contains_key("k"),
"empty values removes the key"
);
}
/// RED-when-broken: an unset coordinate must read as an empty map and never
/// match — a broken key derivation that leaked another coordinate's props, or
/// errored, would fail here.
#[test]
fn missing_coordinate_is_empty_and_never_matches() {
let tmp = tempfile::tempdir().unwrap();
let store = PropertyStore::new(tmp.path());
let coord = id(None, "never-set", "9.9.9");
assert!(store.get("r", &coord).is_empty());
assert!(
!store.matches("r", &coord, "env", "prod"),
"unset coord must not match"
);
}
/// RED-when-broken: properties on coordinate A must NOT be visible on a
/// DIFFERENT coordinate B, nor across repos (repo is part of the key).
#[test]
fn properties_do_not_leak_across_coordinate_or_repo() {
let tmp = tempfile::tempdir().unwrap();
let store = PropertyStore::new(tmp.path());
let a = id(Some("org.a"), "widget", "1.0");
let b = id(Some("org.b"), "widget", "1.0");
store
.set("maven-prod", &a, "env", vec!["prod".into()])
.unwrap();
// Different coordinate → no match.
assert!(
!store.matches("maven-prod", &b, "env", "prod"),
"coordinate B must not see A's props"
);
assert!(store.get("maven-prod", &b).is_empty());
// Same coordinate, different repo → no match (repo is part of the key).
assert!(
!store.matches("maven-dev", &a, "env", "prod"),
"repo is part of the key"
);
}
#[test]
fn matches_key_exists_vs_exact_value() {
let tmp = tempfile::tempdir().unwrap();
let store = PropertyStore::new(tmp.path());
let coord = id(None, "x", "1");
store.set("r", &coord, "env", vec!["prod".into()]).unwrap();
// Empty value = "key exists".
assert!(store.matches("r", &coord, "env", ""));
// Exact value match.
assert!(store.matches("r", &coord, "env", "prod"));
// Wrong value → no match.
assert!(!store.matches("r", &coord, "env", "staging"));
// Wrong key → no match.
assert!(!store.matches("r", &coord, "team", "prod"));
}
/// The awkward values a property store has to carry verbatim: unicode, an
/// empty string, something that looks like the store's own `key=value`
/// delimiter, and something that looks like the `__` coordinate delimiter.
/// RED-when-broken if a value is ever re-parsed instead of stored as data.
#[test]
fn values_round_trip_unicode_empty_and_delimiter_lookalikes() {
let tmp = tempfile::tempdir().unwrap();
let store = PropertyStore::new(tmp.path());
let coord = id(None, "räksmörgås", "1.0.0-β");
let values = vec![
String::new(),
"env=prod".to_string(),
"a__b".to_string(),
"smörgås 🔒".to_string(),
"{\"json\":true}".to_string(),
];
store
.set("rust-prod", &coord, "note", values.clone())
.unwrap();
assert_eq!(
store.get("rust-prod", &coord).get("note"),
Some(&values),
"every value comes back byte-identical, in order"
);
assert!(
store.matches("rust-prod", &coord, "note", "env=prod"),
"exact value match"
);
assert!(store.matches("rust-prod", &coord, "note", "smörgås 🔒"));
assert!(
!store.matches("rust-prod", &coord, "note", "env"),
"no prefix matching"
);
}
/// A property document that does not parse reads as NO properties — the
/// deliberate choice documented on `get` (a search over a mixed store must not
/// sink on one bad file). Pinned so the tolerance is a decision, not an
/// accident, and so the fail-CLOSED half is guarded too: an unreadable document
/// never matches, so it can never satisfy a `signed=true` promotion gate or a
/// `keep=true` retention pin.
#[test]
fn an_unparseable_document_reads_as_no_properties_and_matches_nothing() {
let tmp = tempfile::tempdir().unwrap();
let store = PropertyStore::new(tmp.path());
let coord = id(None, "x", "1");
store
.set("r", &coord, "signed", vec!["true".into()])
.unwrap();
assert!(store.matches("r", &coord, "signed", "true"));
std::fs::write(store.storage_path("r", &coord).unwrap(), b"{not json").unwrap();
assert!(
store.get("r", &coord).is_empty(),
"a corrupt doc reads as empty"
);
assert!(
!store.matches("r", &coord, "signed", "true"),
"and it can never satisfy a gate — the property axis fails CLOSED"
);
}
/// The write is staged through a temp file, so a reader must never mistake the
/// staging file for the document: a leftover `.tmp` (the shape a crash between
/// write and rename leaves) changes nothing a reader sees.
#[test]
fn a_leftover_staging_file_is_not_the_document() {
let tmp = tempfile::tempdir().unwrap();
let store = PropertyStore::new(tmp.path());
let coord = id(None, "x", "1");
store.set("r", &coord, "env", vec!["prod".into()]).unwrap();
let doc = store.storage_path("r", &coord).unwrap();
std::fs::write(doc.with_extension("json.tmp"), br#"{"env":["PWNED"]}"#).unwrap();
assert_eq!(
store.get("r", &coord).get("env"),
Some(&vec!["prod".to_string()]),
"the committed document is the only one a reader sees"
);
// …and a completed write leaves no staging file of its own behind.
store.set("r", &coord, "team", vec!["core".into()]).unwrap();
let files: Vec<String> = std::fs::read_dir(doc.parent().unwrap())
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| !n.ends_with(".json.tmp"))
.collect();
assert_eq!(files.len(), 1, "exactly one committed document: {files:?}");
}
/// Clearing the last key removes the document entirely — a coordinate with no
/// properties leaves no file behind — and the coordinate then reads as unset
/// rather than as an empty-but-present document.
#[test]
fn clearing_the_last_key_removes_the_document() {
let tmp = tempfile::tempdir().unwrap();
let store = PropertyStore::new(tmp.path());
let coord = id(None, "x", "1");
store.set("r", &coord, "env", vec!["prod".into()]).unwrap();
store.set("r", &coord, "team", vec!["core".into()]).unwrap();
assert!(store.storage_path("r", &coord).unwrap().exists());
store.set("r", &coord, "env", vec![]).unwrap();
assert!(
store.storage_path("r", &coord).unwrap().exists(),
"one key remains → the doc stays"
);
store.set("r", &coord, "team", vec![]).unwrap();
assert!(
!store.storage_path("r", &coord).unwrap().exists(),
"the last key gone ⇒ no document is left behind"
);
assert!(store.get("r", &coord).is_empty());
// Removing a key that was never there is a no-op, not an error.
store.set("r", &coord, "never", vec![]).unwrap();
assert!(!store.storage_path("r", &coord).unwrap().exists());
}
/// A repository name that is not already its own storage key is REFUSED, not
/// mapped onto one that is.
///
/// This is the finding, stated as a test. A door gates on the raw string:
/// `role_for_repo("rust/prod")` matches no per-repo rule and falls through to
/// the permissive default, and the reserved-key ADMIN floor is consulted for
/// that same raw string. The store then wrote to `sanitize_repo("rust/prod")`
/// = `rust_prod` — a real repository the caller may be denied on. So a caller
/// with a write anywhere could label, and with admin on any repo of their own
/// could clear `quarantine=true` on, artifacts in a repository they were never
/// granted. The two strings must be one string or the request must die.
#[test]
fn a_repository_name_that_is_not_its_own_storage_key_is_refused_not_redirected() {
let tmp = tempfile::tempdir().unwrap();
let store = PropertyStore::new(tmp.path());
let coord = id(None, "serde", "1.0.0");
// The real repository, written the honest way.
store
.set("rust_prod", &coord, "quarantine", vec!["true".into()])
.unwrap();
// The same bytes addressed under a name that only SANITIZES to it.
for alias in ["rust/prod", "rust:prod", "rust prod", "rust\\prod"] {
let err = store
.set(alias, &coord, "quarantine", vec![])
.expect_err("an alias must not reach the repository it sanitizes onto");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert!(
err.to_string().contains(&format!("{alias:?}")),
"the refusal must name the string it refused: {err}"
);
assert!(
store.get(alias, &coord).is_empty(),
"and a READ through the alias must not leak the real repository's properties"
);
}
assert!(
store.matches("rust_prod", &coord, "quarantine", "true"),
"the real repository's quarantine flag is untouched"
);
}
/// The coordinate is NOT held to the same rule, and that is a decision, not
/// an oversight: `serde/x` and `serde_x` do share one document, but they share
/// a repository too, and holger authorizes per repository — so the collision
/// crosses no boundary, while refusing it would reject artifact names that are
/// merely unusual. This test pins the decision so a change to it is deliberate.
#[test]
fn two_coordinates_that_collapse_together_share_a_document_within_one_repository() {
let tmp = tempfile::tempdir().unwrap();
let store = PropertyStore::new(tmp.path());
store
.set("r", &id(None, "serde_x", "1.0"), "env", vec!["prod".into()])
.unwrap();
store
.set("r", &id(None, "serde/x", "1.0"), "env", vec!["dev".into()])
.unwrap();
assert!(
store.matches("r", &id(None, "serde_x", "1.0"), "env", "dev"),
"same repository, so the collision is inside one authorization scope"
);
}
/// With a roster, a write naming a repository the server does not serve is
/// refused — it no longer invents a directory nobody will ever clean up, and
/// the property search axis cannot be seeded for coordinates that do not exist.
#[test]
fn a_roster_refuses_a_repository_this_server_does_not_serve() {
let tmp = tempfile::tempdir().unwrap();
let roster: Arc<BTreeSet<String>> =
Arc::new(["rust-prod".to_string()].into_iter().collect());
let store = PropertyStore::with_roster(tmp.path(), roster);
let coord = id(None, "serde", "1.0.0");
store
.set("rust-prod", &coord, "env", vec!["prod".into()])
.unwrap();
let err = store
.set("rust-dev", &coord, "env", vec!["dev".into()])
.expect_err("an unknown repository must be refused");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert!(store.get("rust-dev", &coord).is_empty());
assert_eq!(
std::fs::read_dir(tmp.path()).unwrap().count(),
1,
"no directory was created for the repository that does not exist"
);
}
/// Rosterless is still the CLI / in-process shape, and it still keeps every
/// document a direct grandchild of the root for the names it DOES accept.
#[test]
fn an_accepted_coordinate_is_always_exactly_two_segments_below_the_root() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("holger-props");
let store = PropertyStore::new(&root);
let coord = id(Some("acme"), "widget", "2.1.0");
let path = store.storage_path("rust-prod", &coord).unwrap();
assert!(path.starts_with(&root), "path escaped the root: {path:?}");
assert_eq!(
path.strip_prefix(&root).unwrap().components().count(),
2,
"always exactly <repo>/<coord>: {path:?}"
);
}
/// The ONE predicate parser behind the promotion requirement, the quarantine
/// gate and the retention pin — including the forms that must be rejected and
/// the ones where the value itself contains the delimiter.
#[test]
fn parse_predicate_covers_every_form_including_the_rejected_ones() {
assert_eq!(
parse_predicate("signed=true"),
Some(("signed".into(), "true".into()))
);
assert_eq!(
parse_predicate(" signed "),
Some(("signed".into(), String::new())),
"a bare key means any value"
);
assert_eq!(
parse_predicate("signed="),
Some(("signed".into(), String::new())),
"an explicit empty value also means any value"
);
assert_eq!(
parse_predicate(" env = prod "),
Some(("env".into(), " prod".into())),
"the whole predicate is trimmed, then the key: inner padding stays in the value"
);
assert_eq!(
parse_predicate("url=https://x?a=b"),
Some(("url".into(), "https://x?a=b".into())),
"only the FIRST '=' splits — a value may contain more"
);
assert_eq!(parse_predicate(""), None);
assert_eq!(parse_predicate(" "), None);
assert_eq!(
parse_predicate("=true"),
None,
"an empty key is meaningless"
);
assert_eq!(parse_predicate(" =true"), None);
}
/// The pure matching rule itself, straight over a map: presence, exact value,
/// one value among several, and the two ways to miss.
#[test]
fn map_matches_is_presence_or_exact_membership() {
let mut map = PropertyMap::new();
map.insert("env".into(), vec!["prod".into(), "staging".into()]);
map.insert("empty".into(), vec![String::new()]);
assert!(
map_matches(&map, "env", ""),
"empty query value = key present"
);
assert!(map_matches(&map, "env", "prod"));
assert!(
map_matches(&map, "env", "staging"),
"any value in the set matches"
);
assert!(
!map_matches(&map, "env", "PROD"),
"matching is case-SENSITIVE"
);
assert!(
!map_matches(&map, "env", "pro"),
"and exact, not a substring"
);
assert!(
!map_matches(&map, "missing", ""),
"an absent key never matches"
);
assert!(
map_matches(&map, "empty", ""),
"a key holding the empty string is present"
);
}
#[test]
fn overwriting_a_key_is_last_wins_and_keeps_other_keys() {
let tmp = tempfile::tempdir().unwrap();
let store = PropertyStore::new(tmp.path());
let coord = id(None, "x", "1");
store.set("r", &coord, "env", vec!["prod".into()]).unwrap();
store.set("r", &coord, "team", vec!["core".into()]).unwrap();
store
.set("r", &coord, "env", vec!["staging".into()])
.unwrap(); // overwrite env only
let got = store.get("r", &coord);
assert_eq!(
got.get("env").unwrap(),
&vec!["staging".to_string()],
"env overwritten"
);
assert_eq!(
got.get("team").unwrap(),
&vec!["core".to_string()],
"team preserved"
);
}
}