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