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