holger-server-lib 0.6.9

Holger server library: config, wiring, gRPC service, Rust API
//! 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;
use std::path::PathBuf;
use std::sync::Arc;

use traits::ArtifactId;

use crate::promote::{coord_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,
}

/// 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() }
    }

    /// The on-disk path hosting the property document for `(repo, id)`.
    fn doc_path(&self, repo: &str, id: &ArtifactId) -> PathBuf {
        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).
    pub fn get(&self, repo: &str, id: &ArtifactId) -> PropertyMap {
        match std::fs::read(self.doc_path(repo, id)) {
            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 path = self.doc_path(repo, id);
        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,
    }
}

/// 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 shared by the promotion requirement + the quarantine gate (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"));
    }

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