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/search.rs

//! Cross-repo artifact SEARCH — a thin, read-only aggregate over every backend's
//! `list()` + `archive_files()` (parity §H, `.nornir/search-design.md`).
//!
//! The engine is a **pure function** over a repo list: hand it
//! `&[(name, Arc<dyn RepositoryBackendTrait>)]` (the FastRoutes table, or the
//! whole config's repositories) and a [`SearchQuery`], and it returns
//! [`SearchResults`]. No I/O of its own beyond calling the backends it is given,
//! so it is unit-testable with in-memory mock backends and reused verbatim by all
//! three surfaces: the `SearchService` gRPC RPC, the read-only `GET /-/search`
//! HTTP door, and the `holger-server search` CLI verb.
//!
//! Matching axes (all supplied criteria are AND-ed):
//!   * `name`      — case-insensitive substring on the artifact name.
//!   * `namespace` — case-insensitive substring on the namespace (Maven groupId
//!     / npm scope); an artifact with no namespace never matches a namespace query.
//!   * `version`   — EXACT match on the version (versions are exact tokens).
//!   * `checksum`  — lowercase-hex sha256 of the artifact CONTENT (case-insensitive).
//!     This is the one expensive axis: it fetches + hashes each candidate's bytes,
//!     so it only fires when supplied.
//!   * `path`      — case-insensitive substring on a RAW archive file path; an
//!     independent axis that yields [`PathHit`]s.
//!
//! A bare query (no criteria) lists every artifact, bounded by `limit`. A
//! path-only query yields only path hits.

use std::sync::Arc;

use serde::{Deserialize, Serialize};

use traits::{ArtifactId, RepositoryBackendTrait};

/// Default cap on hits returned per kind when the query leaves `limit` at 0.
pub const DEFAULT_SEARCH_LIMIT: usize = 1000;

/// **How many entries one repository may be LISTED for, per search.**
///
/// Distinct from [`DEFAULT_SEARCH_LIMIT`], which caps hits RETURNED. The listing
/// is what costs: `backend.list` materializes entries before any filter runs, and
/// this loop used to ask for `0` — the backends' "no limit" — so a search of a
/// million-artifact repository built a million-entry `Vec` before deciding that
/// none of them matched. The hit cap bounded the answer and nothing bounded the
/// work.
///
/// A scan that reaches this cap sets `truncated`, so a bounded search never
/// reports completeness it did not establish. Same order of magnitude as the
/// `/metrics` artifact-count cap in `exposed/http.rs`, and for the same reason:
/// an operability signal, not an inventory.
pub const MAX_SCAN_PER_REPO: usize = 100_000;

/// **How many artifacts one search may READ AND HASH for the checksum axis.**
///
/// The checksum axis is the only one that needs the bytes, so it turns a scan
/// into a full read of everything the other axes did not exclude. A search with
/// no other criteria therefore used to read and SHA-256 every artifact on the
/// server. This bounds that at a number a legitimate lookup never approaches —
/// a checksum search names one artifact and finds it among the first few — while
/// a sweep hits the wall and is reported `truncated`.
pub const MAX_CHECKSUM_FETCHES: usize = 512;

/// A cross-repo search. Every field is optional and they compose (AND). An
/// all-empty query lists everything (bounded by `limit`).
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SearchQuery {
    /// Case-insensitive substring on the artifact name. `None` = any.
    pub name: Option<String>,
    /// Case-insensitive substring on the namespace (Maven groupId / npm scope).
    /// `None` = no namespace constraint.
    pub namespace: Option<String>,
    /// EXACT version match. `None` = any version.
    pub version: Option<String>,
    /// Lowercase-hex sha256 of the artifact content (case-insensitive). Setting
    /// this forces a per-candidate fetch + hash.
    pub checksum: Option<String>,
    /// Case-insensitive substring on a raw archive file path. Yields [`PathHit`]s.
    pub path: Option<String>,
    /// Custom-**property** filters, `(key, value)` — AND-composed with each other
    /// and the coordinate axes. A `value` of `""` means "the artifact has this
    /// key" (any value); a non-empty value is an EXACT match against one of the
    /// key's values. Properties live in a coordinate-keyed side store (not the
    /// backend), so this axis only bites when the engine is run with a
    /// [`PropertyLookup`] (see [`search_repos_with_properties`]); with none, a
    /// property filter matches nothing (fail-closed). Empty = no property
    /// constraint. `.nornir/properties-design.md`.
    pub properties: Vec<(String, String)>,
    /// Restrict the search to these repository names. Empty = every repo.
    pub repositories: Vec<String>,
    /// Cap on hits per kind; 0 ⇒ [`DEFAULT_SEARCH_LIMIT`].
    pub limit: usize,
}

/// Resolves the custom properties attached to an artifact coordinate — the seam
/// the `property` search axis matches against. Implemented by
/// `properties::PropertyStore`; kept as a trait here so the engine stays pure and
/// unit-testable with an in-memory lookup, never touching on-disk layout.
pub trait PropertyLookup {
    /// The key→values map attached to `(repo, id)` (empty when none).
    fn properties_of(
        &self,
        repo: &str,
        id: &ArtifactId,
    ) -> std::collections::BTreeMap<String, Vec<String>>;
}

impl SearchQuery {
    /// Whether any artifact-axis criterion (name/namespace/version/checksum/
    /// property) is set.
    fn has_artifact_criteria(&self) -> bool {
        self.name.is_some()
            || self.namespace.is_some()
            || self.version.is_some()
            || self.checksum.is_some()
            || !self.properties.is_empty()
    }

    /// The effective per-kind hit cap (`limit`, or the default when unset).
    fn effective_limit(&self) -> usize {
        if self.limit == 0 {
            DEFAULT_SEARCH_LIMIT
        } else {
            self.limit
        }
    }

    /// A completely empty query (matches everything, no repo scoping, default cap).
    pub fn is_empty(&self) -> bool {
        !self.has_artifact_criteria() && self.path.is_none() && self.repositories.is_empty()
    }
}

/// Does `(repo, id)` satisfy EVERY property filter in `q`? `props` resolves the
/// artifact's stored properties. With no property filters this is trivially true;
/// with filters but no lookup the caller has already excluded the candidate.
fn properties_match(
    repo: &str,
    id: &ArtifactId,
    q: &SearchQuery,
    props: &dyn PropertyLookup,
) -> bool {
    if q.properties.is_empty() {
        return true;
    }
    let attached = props.properties_of(repo, id);
    q.properties
        .iter()
        .all(|(key, value)| match attached.get(key) {
            Some(values) => value.is_empty() || values.iter().any(|v| v == value),
            None => false,
        })
}

/// One artifact matched by name / coordinate / checksum, tagged with its repo.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ArtifactHit {
    pub repository: String,
    pub id: ArtifactId,
    pub size_bytes: i64,
    pub content_type: String,
    /// sha256 hex of the content — set only when a checksum query forced a hash.
    pub checksum: Option<String>,
}

/// One raw archive path matched by the `path` axis, tagged with its repo.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PathHit {
    pub repository: String,
    pub path: String,
}

/// The result of a [`search_repos`] run.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SearchResults {
    pub artifacts: Vec<ArtifactHit>,
    pub paths: Vec<PathHit>,
    /// True when a hit list hit the `limit` cap and more matches exist.
    pub truncated: bool,
}

/// Lowercase-hex sha256 — the content id checksum search matches against. Kept
/// here (not shared with `lib::sha256_hex`, which is private) so the engine is
/// self-contained and testable in isolation.
fn sha256_hex(data: &[u8]) -> String {
    // LAW #5 dedup: the shared `nornir-hash` leaf (edda) is the ONE hash primitive.
    nornir_hash::sha256_hex(data)
}

/// Whether `haystack` contains `needle`, case-insensitively.
fn ci_contains(haystack: &str, needle: &str) -> bool {
    if needle.is_empty() {
        return true;
    }
    haystack.to_lowercase().contains(&needle.to_lowercase())
}

/// Does this artifact id match the non-checksum artifact axes (name / namespace /
/// version)? Checksum is handled separately (it needs the bytes).
fn id_matches(id: &ArtifactId, q: &SearchQuery) -> bool {
    if let Some(name) = &q.name {
        if !ci_contains(&id.name, name) {
            return false;
        }
    }
    if let Some(ns) = &q.namespace {
        match &id.namespace {
            Some(actual) if ci_contains(actual, ns) => {}
            // A namespace query never matches an artifact that has no namespace.
            _ => return false,
        }
    }
    if let Some(ver) = &q.version {
        if &id.version != ver {
            return false;
        }
    }
    true
}

/// Run a cross-repo search. Pure over the repo list it is given — the only I/O is
/// the backend `list()` / `fetch()` / `archive_files()` calls, so it is reused
/// verbatim by the gRPC RPC, the HTTP door, and the CLI. A backend that errors is
/// skipped (best-effort aggregate: one bad archive never sinks the whole search);
/// the skip is logged.
pub fn search_repos(
    repos: &[(String, Arc<dyn RepositoryBackendTrait>)],
    query: &SearchQuery,
) -> SearchResults {
    search_repos_with_properties(repos, query, None)
}

/// [`search_repos`] plus the custom-**property** axis. Identical for a query with
/// no property filters; when `query.properties` is set, each candidate must also
/// satisfy every `(key, value)` filter, resolved through `props`. With property
/// filters set but `props == None`, no artifact can match them — the property
/// axis fails **closed** (an unconfigured property store never yields false hits).
pub fn search_repos_with_properties(
    repos: &[(String, Arc<dyn RepositoryBackendTrait>)],
    query: &SearchQuery,
    props: Option<&dyn PropertyLookup>,
) -> SearchResults {
    let limit = query.effective_limit();
    // Artifacts are scanned unless the query is path-ONLY (path set, no artifact
    // criteria). A bare (all-empty) query lists everything.
    let run_artifacts = query.has_artifact_criteria() || query.path.is_none();
    let run_paths = query.path.is_some();

    let mut results = SearchResults::default();
    // Budget for the one axis that reads bytes; spent across ALL repositories,
    // because the cost is to the server, not to a repository.
    let mut checksum_fetches_left = MAX_CHECKSUM_FETCHES;

    for (repo_name, backend) in repos {
        // Repo scoping: skip anything not in an explicit `repositories` list.
        if !query.repositories.is_empty() && !query.repositories.iter().any(|r| r == repo_name) {
            continue;
        }

        // Enter while there is still room under the cap, OR the cap is already
        // reached but truncation has not yet been confirmed — so a repo boundary
        // that lands EXACTLY on the cap still detects matches spilling into later
        // repos (otherwise `truncated` would stay false while hits were silently
        // dropped — a completeness signal that lies). Once truncation is known we
        // stop scanning (no wasted listings).
        if run_artifacts
            && backend.caps().listing
            && (results.artifacts.len() < limit || !results.truncated)
        {
            // Bounded listing (was `0` = unbounded). A repository that fills the
            // scan cap may hold matches past it, so the answer says `truncated`
            // rather than claiming it looked at everything.
            match backend.list(None, MAX_SCAN_PER_REPO) {
                Ok(entries) => {
                    if entries.len() >= MAX_SCAN_PER_REPO {
                        results.truncated = true;
                    }
                    for entry in entries {
                        if results.artifacts.len() >= limit {
                            results.truncated = true;
                            break;
                        }
                        if !id_matches(&entry.id, query) {
                            continue;
                        }
                        // Property axis: filter on the coordinate's stored props
                        // BEFORE the (expensive) checksum fetch. Fails closed when
                        // no property lookup is wired.
                        if !query.properties.is_empty() {
                            match props {
                                Some(p) if properties_match(repo_name, &entry.id, query, p) => {}
                                _ => continue,
                            }
                        }
                        // Checksum is the one axis that needs the bytes: fetch +
                        // hash the candidate and compare (case-insensitive hex).
                        let checksum = match &query.checksum {
                            Some(_) if checksum_fetches_left == 0 => {
                                // The read budget for this search is spent. Stop
                                // rather than keep hashing, and say the answer is
                                // partial.
                                results.truncated = true;
                                break;
                            }
                            Some(want) => {
                                checksum_fetches_left -= 1;
                                match backend.fetch(&entry.id) {
                                    Ok(Some(bytes)) => {
                                        let got = sha256_hex(&bytes);
                                        if !got.eq_ignore_ascii_case(want.trim()) {
                                            continue;
                                        }
                                        Some(got)
                                    }
                                    // Can't read the bytes → can't confirm the digest.
                                    Ok(None) => continue,
                                    Err(e) => {
                                        log::warn!(
                                            "search: checksum fetch failed in '{repo_name}': {e}"
                                        );
                                        continue;
                                    }
                                }
                            }
                            None => None,
                        };
                        results.artifacts.push(ArtifactHit {
                            repository: repo_name.clone(),
                            id: entry.id,
                            size_bytes: entry.size_bytes,
                            content_type: entry.content_type,
                            checksum,
                        });
                    }
                }
                Err(e) => log::warn!("search: list failed in repo '{repo_name}': {e}"),
            }
        }

        // Same cap-boundary handling as the artifact axis: keep scanning past a
        // repo whose paths exactly fill the cap so a later repo's overflow still
        // flips `truncated`, and stop once truncation is confirmed.
        if run_paths && (results.paths.len() < limit || !results.truncated) {
            let needle = query.path.as_deref().unwrap_or_default();
            let listed = if backend.caps().archive {
                backend.archive().map(|a| a.paths(None))
            } else {
                Ok(Vec::new())
            };
            match listed {
                Ok(paths) => {
                    for p in paths {
                        if results.paths.len() >= limit {
                            results.truncated = true;
                            break;
                        }
                        if ci_contains(&p, needle) {
                            results.paths.push(PathHit {
                                repository: repo_name.clone(),
                                path: p,
                            });
                        }
                    }
                }
                Err(e) => {
                    log::warn!("search: archive_files failed in repo '{repo_name}': {e}")
                }
            }
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;
    use async_trait::async_trait;
    use traits::{ArtifactEntry, ArtifactFormat};

    /// A backend that RECORDS what the engine asked it for, and counts reads.
    /// The bounds under test are about the work the engine requests, which no
    /// assertion on the returned hits can see.
    struct CountingRepo {
        name: String,
        entries: Vec<(ArtifactId, Vec<u8>)>,
        list_limits: std::sync::Mutex<Vec<usize>>,
        fetches: std::sync::atomic::AtomicUsize,
    }

    impl CountingRepo {
        fn with_entries(name: &str, n: usize) -> Self {
            Self {
                name: name.into(),
                entries: (0..n)
                    .map(|i| {
                        (
                            ArtifactId {
                                namespace: None,
                                name: format!("crate-{i}"),
                                version: "1.0.0".into(),
                            },
                            format!("bytes of {i}").into_bytes(),
                        )
                    })
                    .collect(),
                list_limits: std::sync::Mutex::new(Vec::new()),
                fetches: std::sync::atomic::AtomicUsize::new(0),
            }
        }
        fn last_list_limit(&self) -> Option<usize> {
            self.list_limits.lock().unwrap().last().copied()
        }
        fn fetch_count(&self) -> usize {
            self.fetches.load(std::sync::atomic::Ordering::SeqCst)
        }
    }

    #[async_trait]
    impl RepositoryBackendTrait for CountingRepo {
        fn caps(&self) -> traits::Caps {
            traits::Caps {
                listing: true,
                ..traits::Caps::NONE
            }
        }
        fn name(&self) -> &str {
            &self.name
        }
        fn format(&self) -> ArtifactFormat {
            ArtifactFormat::Rust
        }
        fn is_writable(&self) -> bool {
            false
        }
        fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<traits::Bytes>> {
            self.fetches
                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
            Ok(self
                .entries
                .iter()
                .find(|(e, _)| e == id)
                .map(|(_, b)| traits::Bytes::from(b.clone())))
        }
        fn put(&self, _id: &ArtifactId, _d: &[u8]) -> anyhow::Result<()> {
            anyhow::bail!("read-only")
        }
        fn list(&self, _f: Option<&str>, limit: usize) -> anyhow::Result<Vec<ArtifactEntry>> {
            self.list_limits.lock().unwrap().push(limit);
            let take = if limit == 0 {
                self.entries.len()
            } else {
                limit.min(self.entries.len())
            };
            Ok(self.entries[..take]
                .iter()
                .map(|(id, bytes)| ArtifactEntry {
                    id: id.clone(),
                    size_bytes: bytes.len() as i64,
                    content_type: "application/octet-stream".into(),
                    // A test double knows neither, and says so.
                    ..ArtifactEntry::UNKNOWN_PROVENANCE
                })
                .collect())
        }
        fn handle_http2_request(
            &self,
            _m: &str,
            _s: &str,
            _b: &[u8],
        ) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
            Ok((200, Vec::new(), Vec::new()))
        }
    }

    /// **A search asks the backend for a BOUNDED listing.**
    ///
    /// RED-when-broken, and it WAS broken: the engine passed `0` — every
    /// backend's "no limit" — so a search of a million-artifact repository
    /// materialized a million entries before deciding none of them matched. The
    /// hit cap bounded the answer and nothing bounded the work.
    #[test]
    fn the_listing_the_engine_requests_is_bounded() {
        let repo = Arc::new(CountingRepo::with_entries("big", 4));
        let repos: Vec<(String, Arc<dyn RepositoryBackendTrait>)> = vec![(
            "big".into(),
            repo.clone() as Arc<dyn RepositoryBackendTrait>,
        )];
        let _ = search_repos(&repos, &SearchQuery::default());
        assert_eq!(
            repo.last_list_limit(),
            Some(MAX_SCAN_PER_REPO),
            "the engine must ask for a bounded listing, not `0`"
        );
    }

    /// **The checksum axis has a read budget, and spending it is reported.**
    ///
    /// RED-when-broken: without the budget, one unauthenticated `?checksum=…`
    /// read and SHA-256'd every artifact in every repository. The budget stops
    /// at a number a real lookup never approaches, and the answer says
    /// `truncated` rather than claiming it searched everything.
    #[test]
    fn the_checksum_axis_stops_at_its_read_budget_and_says_so() {
        let repo = Arc::new(CountingRepo::with_entries("big", MAX_CHECKSUM_FETCHES + 64));
        let repos: Vec<(String, Arc<dyn RepositoryBackendTrait>)> = vec![(
            "big".into(),
            repo.clone() as Arc<dyn RepositoryBackendTrait>,
        )];
        let q = SearchQuery {
            // A digest nothing matches, so every entry is a fetch candidate.
            checksum: Some("f".repeat(64)),
            ..Default::default()
        };
        let res = search_repos(&repos, &q);
        assert!(res.artifacts.is_empty(), "nothing matches that digest");
        assert_eq!(
            repo.fetch_count(),
            MAX_CHECKSUM_FETCHES,
            "the search must stop reading at its budget, not at the end of the repository"
        );
        assert!(
            res.truncated,
            "a search that stopped early must not report completeness"
        );
    }

    /// In-memory backend: a fixed set of (id → bytes) plus a fixed archive path
    /// list. `list()` honours a name substring; `fetch()` returns the seeded
    /// bytes (so checksum search is real); `archive_files()` returns the paths.
    struct MockRepo {
        name: String,
        entries: Vec<(ArtifactId, Vec<u8>)>,
        paths: Vec<String>,
    }

    #[async_trait]
    impl RepositoryBackendTrait for MockRepo {
        fn caps(&self) -> traits::Caps {
            traits::Caps {
                listing: true,
                archive: true,
                ..traits::Caps::NONE
            }
        }
        fn name(&self) -> &str {
            &self.name
        }
        fn format(&self) -> ArtifactFormat {
            ArtifactFormat::Rust
        }
        fn is_writable(&self) -> bool {
            false
        }
        fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<traits::Bytes>> {
            Ok(self
                .entries
                .iter()
                .find(|(e, _)| e == id)
                .map(|(_, b)| traits::Bytes::from(b.clone())))
        }
        fn put(&self, _id: &ArtifactId, _data: &[u8]) -> anyhow::Result<()> {
            anyhow::bail!("read-only mock")
        }
        fn list(
            &self,
            name_filter: Option<&str>,
            _limit: usize,
        ) -> anyhow::Result<Vec<ArtifactEntry>> {
            Ok(self
                .entries
                .iter()
                .filter(|(id, _)| match name_filter {
                    Some(f) => id.name.contains(f),
                    None => true,
                })
                .map(|(id, bytes)| ArtifactEntry {
                    id: id.clone(),
                    size_bytes: bytes.len() as i64,
                    content_type: "application/octet-stream".into(),
                    // A test double knows neither, and says so.
                    ..ArtifactEntry::UNKNOWN_PROVENANCE
                })
                .collect())
        }
        fn archive(&self) -> anyhow::Result<traits::Archive> {
            Ok(traits::Archive {
                name: self.name.clone(),
                files: self.paths.iter().map(|p| (p.clone(), 0)).collect(),
            })
        }
        fn handle_http2_request(
            &self,
            _m: &str,
            _s: &str,
            _b: &[u8],
        ) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
            Ok((404, vec![], Vec::new()))
        }
    }

    fn id(ns: Option<&str>, name: &str, ver: &str) -> ArtifactId {
        ArtifactId {
            namespace: ns.map(|s| s.to_string()),
            name: name.into(),
            version: ver.into(),
        }
    }

    /// Two repos: `rust-dev` holds serde + tokio crates; `maven-dev` holds a
    /// coordinate under group `org.example`.
    fn corpus() -> Vec<(String, Arc<dyn RepositoryBackendTrait>)> {
        let rust: Arc<dyn RepositoryBackendTrait> = Arc::new(MockRepo {
            name: "rust-dev".into(),
            entries: vec![
                (id(None, "serde", "1.0.0"), b"serde-bytes".to_vec()),
                (id(None, "serde", "1.0.1"), b"serde-newer".to_vec()),
                (id(None, "tokio", "1.40.0"), b"tokio-bytes".to_vec()),
            ],
            paths: vec![
                "serde/serde-1.0.0.crate".into(),
                "tokio/tokio-1.40.0.crate".into(),
            ],
        });
        let maven: Arc<dyn RepositoryBackendTrait> = Arc::new(MockRepo {
            name: "maven-dev".into(),
            entries: vec![(
                id(Some("org.example"), "widget", "2.3.4"),
                b"widget-jar".to_vec(),
            )],
            paths: vec!["org/example/widget/2.3.4/widget-2.3.4.jar".into()],
        });
        vec![("rust-dev".into(), rust), ("maven-dev".into(), maven)]
    }

    #[test]
    fn name_search_finds_matches_and_excludes_non_matches() {
        let repos = corpus();
        let q = SearchQuery {
            name: Some("serde".into()),
            ..Default::default()
        };
        let r = search_repos(&repos, &q);
        // Both serde versions hit; the exclusion is load-bearing (RED-when-broken).
        assert_eq!(r.artifacts.len(), 2, "both serde versions match");
        assert!(
            r.artifacts.iter().all(|h| h.id.name == "serde"),
            "only serde artifacts — tokio + widget must be EXCLUDED"
        );
        assert!(
            !r.artifacts.iter().any(|h| h.id.name == "tokio"),
            "tokio must NOT appear in a serde search"
        );
    }

    #[test]
    fn name_search_is_case_insensitive_substring() {
        let repos = corpus();
        let q = SearchQuery {
            name: Some("SER".into()),
            ..Default::default()
        };
        let r = search_repos(&repos, &q);
        assert_eq!(
            r.artifacts.len(),
            2,
            "case-insensitive substring 'SER' matches serde"
        );
    }

    #[test]
    fn gavc_namespace_and_version_narrow_the_set() {
        let repos = corpus();
        // Namespace (Maven groupId) axis: only the org.example coordinate.
        let by_group = search_repos(
            &repos,
            &SearchQuery {
                namespace: Some("org.example".into()),
                ..Default::default()
            },
        );
        assert_eq!(by_group.artifacts.len(), 1);
        assert_eq!(by_group.artifacts[0].id.name, "widget");
        assert_eq!(by_group.artifacts[0].repository, "maven-dev");

        // A namespace query must EXCLUDE the namespace-less rust crates.
        assert!(
            !by_group.artifacts.iter().any(|h| h.id.namespace.is_none()),
            "namespace query must not match namespace-less artifacts"
        );

        // Exact version axis: only serde 1.0.0, not 1.0.1.
        let by_ver = search_repos(
            &repos,
            &SearchQuery {
                name: Some("serde".into()),
                version: Some("1.0.0".into()),
                ..Default::default()
            },
        );
        assert_eq!(by_ver.artifacts.len(), 1, "exact version selects one");
        assert_eq!(by_ver.artifacts[0].id.version, "1.0.0");
    }

    #[test]
    fn checksum_search_matches_content_digest_and_reports_it() {
        let repos = corpus();
        let want = sha256_hex(b"tokio-bytes");
        let r = search_repos(
            &repos,
            &SearchQuery {
                checksum: Some(want.clone()),
                ..Default::default()
            },
        );
        assert_eq!(
            r.artifacts.len(),
            1,
            "exactly the artifact whose bytes hash to `want`"
        );
        assert_eq!(r.artifacts[0].id.name, "tokio");
        assert_eq!(
            r.artifacts[0].checksum.as_deref(),
            Some(want.as_str()),
            "the matched hit carries the computed sha256"
        );
    }

    #[test]
    fn checksum_search_excludes_non_matching_content() {
        let repos = corpus();
        // A digest of bytes that live in NO repo → zero hits (RED-when-broken:
        // a broken checksum comparison would leak every artifact through).
        let bogus = sha256_hex(b"these-bytes-are-nowhere");
        let r = search_repos(
            &repos,
            &SearchQuery {
                checksum: Some(bogus),
                ..Default::default()
            },
        );
        assert!(
            r.artifacts.is_empty(),
            "no artifact hashes to a bogus digest"
        );
    }

    #[test]
    fn repo_scoping_restricts_the_search() {
        let repos = corpus();
        // Empty name lists all, but scoped to maven-dev only.
        let r = search_repos(
            &repos,
            &SearchQuery {
                repositories: vec!["maven-dev".into()],
                ..Default::default()
            },
        );
        assert!(
            r.artifacts.iter().all(|h| h.repository == "maven-dev"),
            "repo scoping must exclude rust-dev artifacts"
        );
        assert_eq!(r.artifacts.len(), 1);
    }

    #[test]
    fn path_search_matches_raw_archive_paths_only() {
        let repos = corpus();
        let r = search_repos(
            &repos,
            &SearchQuery {
                path: Some(".jar".into()),
                ..Default::default()
            },
        );
        // Path-only query yields path hits, no artifact hits.
        assert!(
            r.artifacts.is_empty(),
            "path-only query returns no artifact hits"
        );
        assert_eq!(r.paths.len(), 1, "one .jar path across both repos");
        assert!(r.paths[0].path.ends_with("widget-2.3.4.jar"));
        assert_eq!(r.paths[0].repository, "maven-dev");
    }

    #[test]
    fn empty_query_lists_every_artifact_across_repos() {
        let repos = corpus();
        let r = search_repos(&repos, &SearchQuery::default());
        assert_eq!(r.artifacts.len(), 4, "all 4 artifacts across the 2 repos");
        assert!(r.paths.is_empty(), "no path axis set ⇒ no path hits");
    }

    #[test]
    fn limit_caps_hits_and_flags_truncation() {
        let repos = corpus();
        let r = search_repos(
            &repos,
            &SearchQuery {
                limit: 2,
                ..Default::default()
            },
        );
        assert_eq!(r.artifacts.len(), 2, "capped at the limit");
        assert!(
            r.truncated,
            "more artifacts existed than the cap ⇒ truncated"
        );
    }

    /// RED-when-broken: the cap lands EXACTLY on a repo boundary. `rust-dev` has 3
    /// artifacts and the limit is 3, so the first repo alone fills the cap with no
    /// trailing entry to trip the in-repo truncation check — yet `maven-dev` still
    /// holds a matching artifact (widget) that gets dropped. `truncated` MUST be
    /// true: a client that trusted a false `truncated` here would silently miss
    /// widget and believe its result set complete. The pre-fix code skipped every
    /// later repo once the cap was hit (`results.artifacts.len() < limit` guard),
    /// leaving `truncated == false` — this assertion turns RED against it.
    #[test]
    fn truncated_set_when_cap_lands_on_a_repo_boundary() {
        let repos = corpus(); // rust-dev: 3 artifacts, maven-dev: 1 artifact
        let r = search_repos(
            &repos,
            &SearchQuery {
                limit: 3,
                ..Default::default()
            },
        );
        assert_eq!(
            r.artifacts.len(),
            3,
            "capped at exactly the first repo's count"
        );
        assert!(
            r.truncated,
            "a matching artifact in a later repo was dropped ⇒ MUST report truncated"
        );
        // The dropped one is maven-dev's widget — proving the result is incomplete.
        assert!(
            !r.artifacts.iter().any(|h| h.id.name == "widget"),
            "widget is the dropped-over-the-cap hit that makes truncation load-bearing"
        );
    }

    /// The counterpart: when the cap exactly equals the TOTAL number of matches
    /// across every repo, nothing is dropped, so `truncated` must stay false — the
    /// fix must not over-report on an exactly-complete result.
    #[test]
    fn not_truncated_when_cap_equals_total_matches() {
        let repos = corpus(); // 4 artifacts total across the two repos
        let r = search_repos(
            &repos,
            &SearchQuery {
                limit: 4,
                ..Default::default()
            },
        );
        assert_eq!(r.artifacts.len(), 4, "every artifact fits under the cap");
        assert!(
            !r.truncated,
            "an exactly-complete result must NOT be flagged truncated"
        );
    }

    // === Property axis (§8) ===

    use std::collections::BTreeMap;

    /// In-memory property lookup: `(repo, name, version) → { key: [values] }`.
    struct MockProps(
        std::collections::HashMap<(String, String, String), BTreeMap<String, Vec<String>>>,
    );
    impl MockProps {
        fn new() -> Self {
            Self(std::collections::HashMap::new())
        }
        fn with(mut self, repo: &str, name: &str, ver: &str, key: &str, vals: &[&str]) -> Self {
            self.0
                .entry((repo.into(), name.into(), ver.into()))
                .or_default()
                .insert(key.into(), vals.iter().map(|s| s.to_string()).collect());
            self
        }
    }
    impl PropertyLookup for MockProps {
        fn properties_of(&self, repo: &str, id: &ArtifactId) -> BTreeMap<String, Vec<String>> {
            self.0
                .get(&(repo.to_string(), id.name.clone(), id.version.clone()))
                .cloned()
                .unwrap_or_default()
        }
    }

    /// RED-when-broken: a `property` filter must return ONLY artifacts carrying it
    /// and EXCLUDE those that don't. A broken axis that ignored the filter would
    /// return all four artifacts here.
    #[test]
    fn property_axis_includes_only_tagged_and_excludes_untagged() {
        let repos = corpus();
        let props = MockProps::new()
            .with("rust-dev", "serde", "1.0.0", "env", &["prod"])
            .with("maven-dev", "widget", "2.3.4", "env", &["prod"]);
        let q = SearchQuery {
            properties: vec![("env".into(), "prod".into())],
            ..Default::default()
        };
        let r = search_repos_with_properties(&repos, &q, Some(&props));
        assert_eq!(r.artifacts.len(), 2, "only the two env=prod artifacts");
        assert!(r
            .artifacts
            .iter()
            .any(|h| h.id.name == "serde" && h.id.version == "1.0.0"));
        assert!(r.artifacts.iter().any(|h| h.id.name == "widget"));
        // serde 1.0.1 + tokio are untagged → MUST be excluded.
        assert!(
            !r.artifacts.iter().any(|h| h.id.version == "1.0.1"),
            "untagged serde 1.0.1 excluded"
        );
        assert!(
            !r.artifacts.iter().any(|h| h.id.name == "tokio"),
            "untagged tokio excluded"
        );
    }

    /// A property filter with NO lookup fails CLOSED — matches nothing, never
    /// leaks every artifact (RED-when-broken: a fail-OPEN default would return 4).
    #[test]
    fn property_filter_without_lookup_matches_nothing() {
        let repos = corpus();
        let q = SearchQuery {
            properties: vec![("env".into(), "prod".into())],
            ..Default::default()
        };
        // Both the wrapper (None) and an explicit None fail closed.
        assert_eq!(
            search_repos(&repos, &q).artifacts.len(),
            0,
            "no store ⇒ no property hits"
        );
        assert_eq!(
            search_repos_with_properties(&repos, &q, None)
                .artifacts
                .len(),
            0,
            "explicit None lookup fails closed"
        );
    }

    /// Property filters AND-compose with each other and with the name axis.
    #[test]
    fn property_filters_and_compose_with_axes() {
        let repos = corpus();
        let props = MockProps::new()
            .with("rust-dev", "serde", "1.0.0", "env", &["prod"])
            .with("rust-dev", "serde", "1.0.0", "team", &["core"])
            .with("rust-dev", "serde", "1.0.1", "env", &["prod"]); // env only, no team
                                                                   // env=prod AND team=core → only serde 1.0.0 (1.0.1 lacks team).
        let q = SearchQuery {
            name: Some("serde".into()),
            properties: vec![
                ("env".into(), "prod".into()),
                ("team".into(), "core".into()),
            ],
            ..Default::default()
        };
        let r = search_repos_with_properties(&repos, &q, Some(&props));
        assert_eq!(
            r.artifacts.len(),
            1,
            "both property filters + name AND-compose"
        );
        assert_eq!(r.artifacts[0].id.version, "1.0.0");
    }

    /// An empty property value means "key present, any value".
    #[test]
    fn property_key_presence_matches_any_value() {
        let repos = corpus();
        let props = MockProps::new().with("rust-dev", "tokio", "1.40.0", "reviewed", &["yes"]);
        let q = SearchQuery {
            properties: vec![("reviewed".into(), String::new())], // any value
            ..Default::default()
        };
        let r = search_repos_with_properties(&repos, &q, Some(&props));
        assert_eq!(r.artifacts.len(), 1);
        assert_eq!(r.artifacts[0].id.name, "tokio");
    }

    /// A backend that cannot be read — a corrupt archive, a permission error —
    /// must not sink the whole cross-repo search: the healthy repos still answer.
    /// This is the documented best-effort aggregate, and it is the difference
    /// between "one repo is broken" and "search is down".
    #[test]
    fn one_broken_backend_does_not_sink_the_search() {
        struct BrokenRepo;

        #[async_trait]
        impl RepositoryBackendTrait for BrokenRepo {
            fn caps(&self) -> traits::Caps {
                traits::Caps {
                    listing: true,
                    archive: true,
                    ..traits::Caps::NONE
                }
            }
            fn name(&self) -> &str {
                "broken"
            }
            fn format(&self) -> ArtifactFormat {
                ArtifactFormat::Rust
            }
            fn is_writable(&self) -> bool {
                false
            }
            fn fetch(&self, _id: &ArtifactId) -> anyhow::Result<Option<traits::Bytes>> {
                anyhow::bail!("archive unreadable")
            }
            fn put(&self, _id: &ArtifactId, _data: &[u8]) -> anyhow::Result<()> {
                anyhow::bail!("read-only")
            }
            fn list(&self, _f: Option<&str>, _l: usize) -> anyhow::Result<Vec<ArtifactEntry>> {
                anyhow::bail!("archive unreadable")
            }
            fn archive(&self) -> anyhow::Result<traits::Archive> {
                anyhow::bail!("archive unreadable")
            }
            fn handle_http2_request(
                &self,
                _m: &str,
                _s: &str,
                _b: &[u8],
            ) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
                Ok((404, vec![], Vec::new()))
            }
        }

        let mut repos = corpus();
        // The broken repo sits FIRST, so a search that gave up on the first error
        // would return nothing at all.
        let broken: Arc<dyn RepositoryBackendTrait> = Arc::new(BrokenRepo);
        repos.insert(0, ("broken".into(), broken));

        let r = search_repos(
            &repos,
            &SearchQuery {
                name: Some("serde".into()),
                ..Default::default()
            },
        );
        assert_eq!(r.artifacts.len(), 2, "the healthy repos still answer");
        assert!(r.artifacts.iter().all(|h| h.repository == "rust-dev"));

        // Same for the independent path axis.
        let r = search_repos(
            &repos,
            &SearchQuery {
                path: Some(".crate".into()),
                ..Default::default()
            },
        );
        assert_eq!(
            r.paths.len(),
            2,
            "the path axis survives a broken archive too"
        );
    }

    /// The checksum axis is a CONTENT identity, so it is matched case-insensitively
    /// and with surrounding whitespace trimmed (a digest pasted from a shell or a
    /// `sha256sum` line). A digest that names no content matches nothing at all —
    /// never everything.
    #[test]
    fn the_checksum_axis_is_case_insensitive_trimmed_and_never_matches_nothing() {
        let repos = corpus();
        let want = sha256_hex(b"serde-bytes");

        for probe in [want.clone(), want.to_uppercase(), format!("  {want}\n")] {
            let r = search_repos(
                &repos,
                &SearchQuery {
                    checksum: Some(probe.clone()),
                    ..Default::default()
                },
            );
            assert_eq!(
                r.artifacts.len(),
                1,
                "the digest {probe:?} must find its artifact"
            );
            assert_eq!(r.artifacts[0].id.version, "1.0.0");
            assert_eq!(
                r.artifacts[0].checksum.as_deref(),
                Some(want.as_str()),
                "the hit reports the digest it actually hashed, in canonical lowercase"
            );
        }

        let unknown = sha256_hex(b"bytes that are in no repository");
        let r = search_repos(
            &repos,
            &SearchQuery {
                checksum: Some(unknown),
                ..Default::default()
            },
        );
        assert!(
            r.artifacts.is_empty(),
            "an unknown digest matches NOTHING, not everything"
        );
    }

    /// A namespace query never matches an artifact that has none — a `groupId`
    /// search must not sweep in every unscoped crate — and a version query is an
    /// EXACT token, not a prefix.
    #[test]
    fn the_namespace_axis_excludes_unscoped_artifacts_and_version_is_exact() {
        let repos = corpus();

        let r = search_repos(
            &repos,
            &SearchQuery {
                namespace: Some("org".into()),
                ..Default::default()
            },
        );
        assert_eq!(r.artifacts.len(), 1, "only the namespaced artifact");
        assert_eq!(r.artifacts[0].id.name, "widget");

        let r = search_repos(
            &repos,
            &SearchQuery {
                version: Some("1.0".into()),
                ..Default::default()
            },
        );
        assert!(
            r.artifacts.is_empty(),
            "'1.0' is not a prefix query — 1.0.0 must NOT match"
        );
        let r = search_repos(
            &repos,
            &SearchQuery {
                version: Some("1.0.0".into()),
                ..Default::default()
            },
        );
        assert_eq!(r.artifacts.len(), 1, "the exact version token matches");
    }
}