Skip to main content

cfgd_core/oci/
mod.rs

1// OCI artifact push/pull for cfgd modules.
2//
3// Implements a minimal OCI Distribution Spec client using ureq (sync HTTP).
4// Supports pushing/pulling module archives with custom media types,
5// registry authentication via Docker config.json, credential helpers, and env vars.
6
7use std::collections::HashMap;
8
9use serde::{Deserialize, Serialize};
10
11use crate::errors::OciError;
12
13mod archive;
14mod auth;
15mod build;
16mod pull;
17mod push;
18mod sign;
19mod transport;
20
21pub use archive::{create_tar_gz, extract_tar_gz};
22pub use auth::RegistryAuth;
23pub use build::{build_module, detect_container_runtime};
24pub use pull::{SignaturePolicy, pull_module};
25pub use push::{
26    current_platform, parse_platform_target, push_module, push_module_multiplatform,
27    rust_arch_to_oci,
28};
29pub use sign::{
30    VerifyOptions, attach_attestation, generate_slsa_provenance, sign_artifact, verify_attestation,
31    verify_signature,
32};
33
34// ---------------------------------------------------------------------------
35// Media type constants
36// ---------------------------------------------------------------------------
37
38pub const MEDIA_TYPE_MODULE_CONFIG: &str = "application/vnd.cfgd.module.config.v1+json";
39pub const MEDIA_TYPE_MODULE_LAYER: &str = "application/vnd.cfgd.module.layer.v1.tar+gzip";
40
41/// OCI image manifest v2 media type.
42pub(super) const MEDIA_TYPE_OCI_MANIFEST: &str = "application/vnd.oci.image.manifest.v1+json";
43
44// ---------------------------------------------------------------------------
45// OCI Reference
46// ---------------------------------------------------------------------------
47
48/// A parsed OCI artifact reference: `[registry/]repository[:tag|@digest]`.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct OciReference {
51    /// Registry hostname (e.g. `ghcr.io`, `docker.io`). Defaults to `docker.io`.
52    pub registry: String,
53    /// Repository path (e.g. `myorg/mymodule`).
54    pub repository: String,
55    /// Tag or digest. Defaults to `latest` if neither specified.
56    pub reference: ReferenceKind,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum ReferenceKind {
61    Tag(String),
62    Digest(String),
63}
64
65impl std::fmt::Display for OciReference {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        match &self.reference {
68            ReferenceKind::Tag(tag) => {
69                write!(f, "{}/{}:{}", self.registry, self.repository, tag)
70            }
71            ReferenceKind::Digest(digest) => {
72                write!(f, "{}/{}@{}", self.registry, self.repository, digest)
73            }
74        }
75    }
76}
77
78impl OciReference {
79    /// Parse an OCI reference string.
80    ///
81    /// Accepted formats:
82    /// - `registry.example.com/repo/name:tag`
83    /// - `registry.example.com/repo/name@sha256:abc...`
84    /// - `repo/name:tag` (defaults to `docker.io`)
85    /// - `repo/name` (defaults to `docker.io`, tag `latest`)
86    /// - `localhost:5000/repo:tag`
87    pub fn parse(reference: &str) -> Result<Self, OciError> {
88        if reference.is_empty()
89            || reference
90                .chars()
91                .any(|c| c.is_whitespace() || c.is_control())
92        {
93            return Err(OciError::InvalidReference {
94                reference: reference.to_string(),
95            });
96        }
97
98        // Split off digest first (@sha256:...)
99        let (name_part, ref_kind) = if let Some((name, digest)) = reference.split_once('@') {
100            (name, ReferenceKind::Digest(digest.to_string()))
101        } else if let Some((name, tag)) = reference.rsplit_once(':') {
102            // Be careful not to split on port numbers. A port number is preceded
103            // by the registry hostname (no slashes after the colon before the next
104            // slash). We check: if the part after ':' contains '/' it's not a tag.
105            // Also, if the name part has no '/' at all, and the tag looks numeric,
106            // it might be a port — but that would make the reference invalid without
107            // a repo path. We handle by checking if 'tag' looks like a port (all digits)
108            // AND the name_part has no slash.
109            if tag.chars().all(|c| c.is_ascii_digit()) && !name.contains('/') {
110                // Looks like host:port with no repo — invalid
111                return Err(OciError::InvalidReference {
112                    reference: reference.to_string(),
113                });
114            }
115            // If the text before ':' ends with a slash-less segment that contains '.',
116            // and the text after ':' contains '/', then it's registry:port/repo/name
117            if tag.contains('/') {
118                // The colon was a port separator, not a tag separator.
119                // Re-parse without splitting on this colon.
120                (reference, ReferenceKind::Tag("latest".to_string()))
121            } else {
122                (name, ReferenceKind::Tag(tag.to_string()))
123            }
124        } else {
125            (reference, ReferenceKind::Tag("latest".to_string()))
126        };
127
128        // Now split name_part into registry and repository.
129        // Convention: if the first path component contains a dot or colon, or is
130        // "localhost", it's a registry hostname. Otherwise, default to docker.io.
131        let parts: Vec<&str> = name_part.splitn(2, '/').collect();
132        let (registry, repository) = if parts.len() == 1 {
133            // Just a name like "ubuntu" — docker.io/library/<name>
134            ("docker.io".to_string(), format!("library/{}", parts[0]))
135        } else {
136            let first = parts[0];
137            let rest = parts[1];
138            if first.contains('.') || first.contains(':') || first == "localhost" {
139                (first.to_string(), rest.to_string())
140            } else {
141                // e.g. "myorg/myrepo" — default registry
142                ("docker.io".to_string(), name_part.to_string())
143            }
144        };
145
146        if repository.is_empty() {
147            return Err(OciError::InvalidReference {
148                reference: reference.to_string(),
149            });
150        }
151
152        Ok(OciReference {
153            registry,
154            repository,
155            reference: ref_kind,
156        })
157    }
158
159    /// The tag string (or digest) used in API paths.
160    pub fn reference_str(&self) -> &str {
161        match &self.reference {
162            ReferenceKind::Tag(t) => t,
163            ReferenceKind::Digest(d) => d,
164        }
165    }
166
167    /// Base API URL for this registry.
168    pub(super) fn api_base(&self) -> String {
169        let scheme = if self.registry == "localhost"
170            || self.registry.starts_with("localhost:")
171            || self.registry.starts_with("127.0.0.1")
172            || is_insecure_registry(&self.registry)
173        {
174            "http"
175        } else {
176            "https"
177        };
178        format!("{scheme}://{}/v2", self.registry)
179    }
180}
181
182/// Check if a registry is listed in `OCI_INSECURE_REGISTRIES` (comma-separated).
183fn is_insecure_registry(registry: &str) -> bool {
184    std::env::var("OCI_INSECURE_REGISTRIES")
185        .unwrap_or_default()
186        .split(',')
187        .any(|r| r.trim() == registry)
188}
189
190// ---------------------------------------------------------------------------
191// OCI Manifest types (OCI Image Manifest v1)
192// ---------------------------------------------------------------------------
193
194#[derive(Debug, Serialize, Deserialize)]
195#[serde(rename_all = "camelCase")]
196pub(super) struct OciManifest {
197    pub(super) schema_version: u32,
198    pub(super) media_type: String,
199    pub(super) config: OciDescriptor,
200    pub(super) layers: Vec<OciDescriptor>,
201    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
202    pub(super) annotations: HashMap<String, String>,
203}
204
205#[derive(Debug, Serialize, Deserialize)]
206#[serde(rename_all = "camelCase")]
207pub(super) struct OciDescriptor {
208    pub(super) media_type: String,
209    pub(super) digest: String,
210    pub(super) size: u64,
211    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
212    pub(super) annotations: HashMap<String, String>,
213}
214
215// ---------------------------------------------------------------------------
216// Test helpers shared across submodule test blocks.
217// ---------------------------------------------------------------------------
218
219#[cfg(test)]
220pub(super) mod test_helpers {
221    /// Helper: extract host:port from a mockito server URL for use as OCI registry.
222    pub(crate) fn registry_from_url(url: &str) -> String {
223        url.trim_start_matches("http://")
224            .trim_start_matches("https://")
225            .trim_end_matches('/')
226            .to_string()
227    }
228
229    /// Helper: create a module directory with a valid module.yaml for push tests.
230    pub(crate) fn create_test_module_dir() -> tempfile::TempDir {
231        let dir = tempfile::tempdir().unwrap();
232        std::fs::write(
233            dir.path().join("module.yaml"),
234            "apiVersion: cfgd.io/v1alpha1\nkind: Module\nmetadata:\n  name: test-mod\nspec:\n  packages:\n    - name: curl\n",
235        )
236        .unwrap();
237        std::fs::write(dir.path().join("README.md"), "# Test module\n").unwrap();
238        dir
239    }
240}
241
242// ---------------------------------------------------------------------------
243// Cross-cutting tests for top-level OCI types and the public surface.
244// ---------------------------------------------------------------------------
245
246#[cfg(test)]
247mod tests;