server/lib/src/group.rs
//! Virtual **group** backend: a read-only repository that owns no storage and
//! aggregates an ordered list of MEMBER backends behind a single repo name — the
//! Nexus/Artifactory "group repository". A client hits one coordinate and holger
//! resolves across the members in declaration order (first hit wins), so a team
//! can expose e.g. `maven-all = [maven-releases, maven-snapshots, central-proxy]`
//! as one URL.
//!
//! Distinct from [`ProxyBackend`](crate::proxy::ProxyBackend): a proxy has a
//! WRITABLE primary plus pull-through upstreams (an upstream hit is cached into
//! the primary); a group has no primary, caches nothing, and is always read-only.
//! `put` is refused. `format` is the members' shared format (first member's —
//! groups are homogeneous by construction; an empty group is `Raw`).
use std::sync::Arc;
use traits::{ArtifactEntry, ArtifactFormat, ArtifactId, RepositoryBackendTrait};
/// A read-only aggregate over ordered member backends (the "group repo").
pub struct GroupBackend {
name: String,
format: ArtifactFormat,
members: Vec<Arc<dyn RepositoryBackendTrait>>,
}
impl GroupBackend {
/// Build a group named `name` over `members` (resolved in order). The group's
/// `format` is the first member's (members are expected homogeneous); an empty
/// group reports [`ArtifactFormat::Raw`] and resolves nothing.
pub fn new(name: String, members: Vec<Arc<dyn RepositoryBackendTrait>>) -> Self {
let format = members
.first()
.map(|m| m.format())
.unwrap_or(ArtifactFormat::Raw);
Self {
name,
format,
members,
}
}
}
impl RepositoryBackendTrait for GroupBackend {
fn name(&self) -> &str {
&self.name
}
fn format(&self) -> ArtifactFormat {
self.format.clone()
}
/// A group never accepts writes — it is a view over its members.
fn is_writable(&self) -> bool {
false
}
/// * listing — only when EVERY member lists (one that cannot makes the
/// merged listing a partial one nobody may count); an empty group cannot.
/// * archive — when ANY member is archive-backed (the Archive tab browses
/// across the group); the group's archive is its members' SUM, which is
/// why a fleet sum skips a [`Cap::Group`](traits::Cap::Group).
/// * path mapping — only when EVERY member maps (fail closed: the
/// quarantine gate reads `None` from a mapping backend as "serve it").
fn caps(&self) -> traits::Caps {
let all = |f: fn(&traits::Caps) -> bool| {
!self.members.is_empty() && self.members.iter().all(|m| f(&m.caps()))
};
traits::Caps {
listing: all(|c| c.listing),
archive: self.members.iter().any(|m| m.caps().archive),
group: true,
path_mapping: all(|c| c.path_mapping),
..traits::Caps::NONE
}
}
/// The first member that recognises `suburl` (members share one path scheme).
fn coordinate_for_path(&self, suburl: &str) -> anyhow::Result<Option<ArtifactId>> {
if !self.caps().path_mapping {
return traits::unsupported(&self.name, traits::Cap::PathMapping);
}
for m in &self.members {
if let Some(id) = m.coordinate_for_path(suburl)? {
return Ok(Some(id));
}
}
Ok(None)
}
/// The members' names in resolution order.
fn group_members(&self) -> anyhow::Result<Vec<String>> {
Ok(self.members.iter().map(|m| m.name().to_string()).collect())
}
/// First member that holds the artifact wins (declaration order).
fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<traits::Bytes>> {
for member in &self.members {
if let Some(data) = member.fetch(id)? {
return Ok(Some(data));
}
}
Ok(None)
}
fn put(&self, _id: &ArtifactId, _data: &[u8]) -> anyhow::Result<()> {
Err(anyhow::anyhow!(
"group repository '{}' is a read-only view — push to a member repo instead",
self.name
))
}
/// Walk the members in order, returning the first non-404 response (the group
/// resolution). If every member 404s (or the group is empty), return a 404.
fn handle_http2_request(
&self,
method: &str,
suburl: &str,
body: &[u8],
) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
let mut last = (404u16, Vec::new(), b"Not found in group".to_vec());
for member in &self.members {
let resp = member.handle_http2_request(method, suburl, body)?;
if resp.0 != 404 {
return Ok(resp);
}
last = resp;
}
Ok(last)
}
/// Merge each member's listing, de-duplicating by full coordinate
/// (namespace + name + version) so an artifact present in two members is
/// listed once (the first member's entry wins). Respects `limit` across the
/// merged result.
fn list(&self, name_filter: Option<&str>, limit: usize) -> anyhow::Result<Vec<ArtifactEntry>> {
if !self.caps().listing {
return traits::unsupported(&self.name, traits::Cap::Listing);
}
let mut seen = std::collections::HashSet::new();
let mut out: Vec<ArtifactEntry> = Vec::new();
for member in &self.members {
for entry in member.list(name_filter, 0)? {
let key = (
entry.id.namespace.clone(),
entry.id.name.clone(),
entry.id.version.clone(),
);
if seen.insert(key) {
out.push(entry);
if limit != 0 && out.len() >= limit {
return Ok(out);
}
}
}
}
Ok(out)
}
/// The archive-backed members' archives, concatenated under the group's
/// name: the paths de-duplicate, the stats are the members' SUM.
fn archive(&self) -> anyhow::Result<traits::Archive> {
if !self.caps().archive {
return traits::unsupported(&self.name, traits::Cap::Archive);
}
let mut files = Vec::new();
for m in self.members.iter().filter(|m| m.caps().archive) {
files.extend(m.archive()?.files);
}
Ok(traits::Archive {
name: self.name.clone(),
files,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
/// A tiny in-memory member: holds a set of (id → bytes) and an HTTP path map.
struct MemberRepo {
maps: bool,
name: String,
format: ArtifactFormat,
blobs: Mutex<Vec<(ArtifactId, Vec<u8>)>>,
http: Mutex<Vec<(String, Vec<u8>)>>,
}
impl MemberRepo {
fn new(name: &str, format: ArtifactFormat) -> Self {
Self {
maps: false,
name: name.into(),
format,
blobs: Mutex::new(Vec::new()),
http: Mutex::new(Vec::new()),
}
}
fn with_blob(self, id: ArtifactId, data: &[u8]) -> Self {
self.blobs.lock().unwrap().push((id, data.to_vec()));
self
}
fn with_http(self, path: &str, data: &[u8]) -> Self {
self.http.lock().unwrap().push((path.into(), data.to_vec()));
self
}
/// Declare that this member can map its own served paths back to a
/// coordinate — what `raw` does and every other shipped backend does not.
fn mapping(mut self) -> Self {
self.maps = true;
self
}
}
impl RepositoryBackendTrait for MemberRepo {
fn caps(&self) -> traits::Caps {
traits::Caps {
listing: true,
path_mapping: self.maps,
..traits::Caps::NONE
}
}
fn coordinate_for_path(&self, _suburl: &str) -> anyhow::Result<Option<ArtifactId>> {
if !self.maps {
return traits::unsupported("member", traits::Cap::PathMapping);
}
Ok(None)
}
fn name(&self) -> &str {
&self.name
}
fn format(&self) -> ArtifactFormat {
self.format.clone()
}
fn is_writable(&self) -> bool {
false
}
fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<traits::Bytes>> {
Ok(self
.blobs
.lock()
.unwrap()
.iter()
.find(|(i, _)| i == id)
.map(|(_, d)| traits::Bytes::from(d.clone())))
}
fn put(&self, _id: &ArtifactId, _data: &[u8]) -> anyhow::Result<()> {
Err(anyhow::anyhow!("read-only"))
}
fn list(&self, _f: Option<&str>, _l: usize) -> anyhow::Result<Vec<ArtifactEntry>> {
Ok(self
.blobs
.lock()
.unwrap()
.iter()
.map(|(id, d)| ArtifactEntry {
id: id.clone(),
size_bytes: d.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,
suburl: &str,
_b: &[u8],
) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
match self.http.lock().unwrap().iter().find(|(p, _)| p == suburl) {
Some((_, d)) => Ok((200, vec![], d.clone())),
None => Ok((404, vec![], b"nf".to_vec())),
}
}
}
fn id(name: &str, version: &str) -> ArtifactId {
ArtifactId {
namespace: None,
name: name.into(),
version: version.into(),
}
}
#[test]
fn fetch_returns_first_member_hit_in_order() {
let a = MemberRepo::new("a", ArtifactFormat::Maven3).with_blob(id("x", "1"), b"from-a");
let b = MemberRepo::new("b", ArtifactFormat::Maven3)
.with_blob(id("x", "1"), b"from-b")
.with_blob(id("y", "2"), b"y-from-b");
let group = GroupBackend::new("maven-all".into(), vec![Arc::new(a), Arc::new(b)]);
// x/1 is in both — the FIRST member wins.
assert_eq!(
group.fetch(&id("x", "1")).unwrap().as_deref(),
Some(b"from-a".as_slice())
);
// y/2 only in the second member — still resolved.
assert_eq!(
group.fetch(&id("y", "2")).unwrap().as_deref(),
Some(b"y-from-b".as_slice())
);
// absent everywhere → None.
assert_eq!(group.fetch(&id("z", "9")).unwrap(), None);
}
#[test]
fn http_returns_first_non_404_member() {
let a = MemberRepo::new("a", ArtifactFormat::Maven3);
let b = MemberRepo::new("b", ArtifactFormat::Maven3).with_http("/g/x", b"body-b");
let group = GroupBackend::new("g".into(), vec![Arc::new(a), Arc::new(b)]);
let (s, _h, body) = group.handle_http2_request("GET", "/g/x", &[]).unwrap();
assert_eq!(s, 200);
assert_eq!(body, b"body-b");
// nothing serves this path → 404.
let (s, _h, _b) = group
.handle_http2_request("GET", "/g/missing", &[])
.unwrap();
assert_eq!(s, 404);
}
#[test]
fn list_merges_and_dedups_by_coordinate() {
let a = MemberRepo::new("a", ArtifactFormat::Maven3).with_blob(id("x", "1"), b"a");
let b = MemberRepo::new("b", ArtifactFormat::Maven3)
.with_blob(id("x", "1"), b"b") // dup coord
.with_blob(id("y", "2"), b"b");
let group = GroupBackend::new("g".into(), vec![Arc::new(a), Arc::new(b)]);
let listed = group.list(None, 0).unwrap();
assert_eq!(listed.len(), 2, "x/1 dedup'd across members, y/2 added");
assert!(listed
.iter()
.any(|e| e.id.name == "x" && e.id.version == "1"));
assert!(listed
.iter()
.any(|e| e.id.name == "y" && e.id.version == "2"));
}
#[test]
fn group_is_read_only_and_takes_member_format() {
let a = MemberRepo::new("a", ArtifactFormat::Npm);
let group = GroupBackend::new("g".into(), vec![Arc::new(a)]);
assert!(!group.is_writable());
assert_eq!(
group.format(),
ArtifactFormat::Npm,
"group inherits its members' format"
);
assert!(
group.put(&id("x", "1"), b"data").is_err(),
"group refuses writes"
);
}
#[test]
fn empty_group_is_raw_and_resolves_nothing() {
let group = GroupBackend::new("empty".into(), vec![]);
assert_eq!(group.format(), ArtifactFormat::Raw);
assert_eq!(group.fetch(&id("x", "1")).unwrap(), None);
let (s, _h, _b) = group.handle_http2_request("GET", "/empty/x", &[]).unwrap();
assert_eq!(s, 404);
}
/// **Fail-closed across members.** The serve-time quarantine gate reads
/// `Caps::path_mapping` to decide what a `None` from
/// `coordinate_for_path` MEANS: on a backend that answers true, `None` is "no
/// artifact at this path, serve it"; on one that answers false, it is "I
/// cannot tell, refuse". A group holding one member that cannot map its own
/// paths must therefore answer false, or that member's artifacts are served
/// ungated — the exact fail-open the gate exists to close, reintroduced one
/// level up by a wrapper.
#[test]
fn a_group_maps_paths_only_when_every_member_does() {
let all = GroupBackend::new(
"g".into(),
vec![
Arc::new(MemberRepo::new("a", ArtifactFormat::Raw).mapping())
as Arc<dyn RepositoryBackendTrait>,
Arc::new(MemberRepo::new("b", ArtifactFormat::Raw).mapping()),
],
);
assert!(all.caps().path_mapping);
let mixed = GroupBackend::new(
"g".into(),
vec![
Arc::new(MemberRepo::new("a", ArtifactFormat::Raw).mapping())
as Arc<dyn RepositoryBackendTrait>,
Arc::new(MemberRepo::new("b", ArtifactFormat::Maven3)),
],
);
assert!(
!mixed.caps().path_mapping,
"one member that cannot map blinds the whole group"
);
let empty = GroupBackend::new("g".into(), vec![]);
assert!(
!empty.caps().path_mapping,
"an empty group can answer for nothing"
);
for g in [&all, &mixed, &empty] {
traits::backend::conformance::check(g).unwrap();
}
}
}