Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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"
        );
    }
}