Skip to main content

cfgd_core/
oci.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;
8use std::io::Read;
9use std::path::Path;
10
11use serde::{Deserialize, Serialize};
12
13use crate::errors::OciError;
14use crate::output::Printer;
15
16// ---------------------------------------------------------------------------
17// Media type constants
18// ---------------------------------------------------------------------------
19
20pub const MEDIA_TYPE_MODULE_CONFIG: &str = "application/vnd.cfgd.module.config.v1+json";
21pub const MEDIA_TYPE_MODULE_LAYER: &str = "application/vnd.cfgd.module.layer.v1.tar+gzip";
22
23/// OCI image manifest v2 media type.
24const MEDIA_TYPE_OCI_MANIFEST: &str = "application/vnd.oci.image.manifest.v1+json";
25
26// ---------------------------------------------------------------------------
27// OCI Reference
28// ---------------------------------------------------------------------------
29
30/// A parsed OCI artifact reference: `[registry/]repository[:tag|@digest]`.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct OciReference {
33    /// Registry hostname (e.g. `ghcr.io`, `docker.io`). Defaults to `docker.io`.
34    pub registry: String,
35    /// Repository path (e.g. `myorg/mymodule`).
36    pub repository: String,
37    /// Tag or digest. Defaults to `latest` if neither specified.
38    pub reference: ReferenceKind,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum ReferenceKind {
43    Tag(String),
44    Digest(String),
45}
46
47impl std::fmt::Display for OciReference {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match &self.reference {
50            ReferenceKind::Tag(tag) => {
51                write!(f, "{}/{}:{}", self.registry, self.repository, tag)
52            }
53            ReferenceKind::Digest(digest) => {
54                write!(f, "{}/{}@{}", self.registry, self.repository, digest)
55            }
56        }
57    }
58}
59
60impl OciReference {
61    /// Parse an OCI reference string.
62    ///
63    /// Accepted formats:
64    /// - `registry.example.com/repo/name:tag`
65    /// - `registry.example.com/repo/name@sha256:abc...`
66    /// - `repo/name:tag` (defaults to `docker.io`)
67    /// - `repo/name` (defaults to `docker.io`, tag `latest`)
68    /// - `localhost:5000/repo:tag`
69    pub fn parse(reference: &str) -> Result<Self, OciError> {
70        if reference.is_empty()
71            || reference
72                .chars()
73                .any(|c| c.is_whitespace() || c.is_control())
74        {
75            return Err(OciError::InvalidReference {
76                reference: reference.to_string(),
77            });
78        }
79
80        // Split off digest first (@sha256:...)
81        let (name_part, ref_kind) = if let Some((name, digest)) = reference.split_once('@') {
82            (name, ReferenceKind::Digest(digest.to_string()))
83        } else if let Some((name, tag)) = reference.rsplit_once(':') {
84            // Be careful not to split on port numbers. A port number is preceded
85            // by the registry hostname (no slashes after the colon before the next
86            // slash). We check: if the part after ':' contains '/' it's not a tag.
87            // Also, if the name part has no '/' at all, and the tag looks numeric,
88            // it might be a port — but that would make the reference invalid without
89            // a repo path. We handle by checking if 'tag' looks like a port (all digits)
90            // AND the name_part has no slash.
91            if tag.chars().all(|c| c.is_ascii_digit()) && !name.contains('/') {
92                // Looks like host:port with no repo — invalid
93                return Err(OciError::InvalidReference {
94                    reference: reference.to_string(),
95                });
96            }
97            // If the text before ':' ends with a slash-less segment that contains '.',
98            // and the text after ':' contains '/', then it's registry:port/repo/name
99            if tag.contains('/') {
100                // The colon was a port separator, not a tag separator.
101                // Re-parse without splitting on this colon.
102                (reference, ReferenceKind::Tag("latest".to_string()))
103            } else {
104                (name, ReferenceKind::Tag(tag.to_string()))
105            }
106        } else {
107            (reference, ReferenceKind::Tag("latest".to_string()))
108        };
109
110        // Now split name_part into registry and repository.
111        // Convention: if the first path component contains a dot or colon, or is
112        // "localhost", it's a registry hostname. Otherwise, default to docker.io.
113        let parts: Vec<&str> = name_part.splitn(2, '/').collect();
114        let (registry, repository) = if parts.len() == 1 {
115            // Just a name like "ubuntu" — docker.io/library/<name>
116            ("docker.io".to_string(), format!("library/{}", parts[0]))
117        } else {
118            let first = parts[0];
119            let rest = parts[1];
120            if first.contains('.') || first.contains(':') || first == "localhost" {
121                (first.to_string(), rest.to_string())
122            } else {
123                // e.g. "myorg/myrepo" — default registry
124                ("docker.io".to_string(), name_part.to_string())
125            }
126        };
127
128        if repository.is_empty() {
129            return Err(OciError::InvalidReference {
130                reference: reference.to_string(),
131            });
132        }
133
134        Ok(OciReference {
135            registry,
136            repository,
137            reference: ref_kind,
138        })
139    }
140
141    /// The tag string (or digest) used in API paths.
142    pub fn reference_str(&self) -> &str {
143        match &self.reference {
144            ReferenceKind::Tag(t) => t,
145            ReferenceKind::Digest(d) => d,
146        }
147    }
148
149    /// Base API URL for this registry.
150    fn api_base(&self) -> String {
151        let scheme = if self.registry == "localhost"
152            || self.registry.starts_with("localhost:")
153            || self.registry.starts_with("127.0.0.1")
154            || is_insecure_registry(&self.registry)
155        {
156            "http"
157        } else {
158            "https"
159        };
160        format!("{scheme}://{}/v2", self.registry)
161    }
162}
163
164/// Check if a registry is listed in `OCI_INSECURE_REGISTRIES` (comma-separated).
165fn is_insecure_registry(registry: &str) -> bool {
166    std::env::var("OCI_INSECURE_REGISTRIES")
167        .unwrap_or_default()
168        .split(',')
169        .any(|r| r.trim() == registry)
170}
171
172// ---------------------------------------------------------------------------
173// Registry Authentication
174// ---------------------------------------------------------------------------
175
176/// Credentials for authenticating to an OCI registry.
177#[derive(Debug, Clone)]
178pub struct RegistryAuth {
179    pub username: String,
180    pub password: String,
181}
182
183/// Docker config.json structure (subset).
184#[derive(Debug, Default, Deserialize)]
185#[serde(rename_all = "camelCase")]
186struct DockerConfig {
187    #[serde(default)]
188    auths: HashMap<String, DockerAuthEntry>,
189    #[serde(default)]
190    cred_helpers: HashMap<String, String>,
191}
192
193#[derive(Debug, Default, Deserialize)]
194struct DockerAuthEntry {
195    auth: Option<String>,
196}
197
198impl RegistryAuth {
199    /// Resolve credentials for the given registry hostname.
200    ///
201    /// Tries, in order:
202    /// 1. `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` environment variables
203    /// 2. Docker config.json (`~/.docker/config.json`) — base64 auth field
204    /// 3. Docker credential helpers (`docker-credential-<helper>`)
205    pub fn resolve(registry: &str) -> Option<Self> {
206        // 1. Environment variables
207        if let (Ok(user), Ok(pass)) = (
208            std::env::var("REGISTRY_USERNAME"),
209            std::env::var("REGISTRY_PASSWORD"),
210        ) && !user.is_empty()
211            && !pass.is_empty()
212        {
213            return Some(RegistryAuth {
214                username: user,
215                password: pass,
216            });
217        }
218
219        // 2. Docker config.json
220        let config_path = docker_config_path();
221        if let Ok(contents) = std::fs::read_to_string(&config_path)
222            && let Ok(config) = serde_json::from_str::<DockerConfig>(&contents)
223        {
224            // Try direct auth entry
225            if let Some(auth) = resolve_from_docker_auths(&config.auths, registry) {
226                return Some(auth);
227            }
228
229            // Try credential helper
230            if let Some(helper) = config.cred_helpers.get(registry)
231                && let Some(auth) = resolve_from_credential_helper(helper, registry)
232            {
233                return Some(auth);
234            }
235        }
236
237        None
238    }
239
240    /// Returns the HTTP Basic auth header value.
241    fn basic_auth_header(&self) -> String {
242        format!(
243            "Basic {}",
244            base64_encode(format!("{}:{}", self.username, self.password).as_bytes())
245        )
246    }
247}
248
249fn docker_config_path() -> std::path::PathBuf {
250    if let Ok(dir) = std::env::var("DOCKER_CONFIG") {
251        return std::path::PathBuf::from(dir).join("config.json");
252    }
253    crate::expand_tilde(std::path::Path::new("~/.docker/config.json"))
254}
255
256/// Try to find credentials in the Docker config auths map.
257/// Keys can be full URLs like `https://ghcr.io` or just hostnames like `ghcr.io`.
258fn resolve_from_docker_auths(
259    auths: &HashMap<String, DockerAuthEntry>,
260    registry: &str,
261) -> Option<RegistryAuth> {
262    // Try exact match and common variants
263    let candidates = [
264        registry.to_string(),
265        format!("https://{}", registry),
266        format!("https://{}/v2/", registry),
267        format!("https://{}/v1/", registry),
268    ];
269
270    for candidate in &candidates {
271        if let Some(entry) = auths.get(candidate)
272            && let Some(ref auth_b64) = entry.auth
273            && let Some(cred) = decode_docker_auth(auth_b64)
274        {
275            return Some(cred);
276        }
277    }
278
279    // For docker.io, also check index.docker.io
280    if registry == "docker.io" || registry == "index.docker.io" {
281        let alt_candidates = [
282            "https://index.docker.io/v1/".to_string(),
283            "index.docker.io".to_string(),
284        ];
285        for candidate in &alt_candidates {
286            if let Some(entry) = auths.get(candidate)
287                && let Some(ref auth_b64) = entry.auth
288                && let Some(cred) = decode_docker_auth(auth_b64)
289            {
290                return Some(cred);
291            }
292        }
293    }
294
295    None
296}
297
298/// Decode a base64 `user:password` auth string from Docker config.
299fn decode_docker_auth(auth_b64: &str) -> Option<RegistryAuth> {
300    let decoded = base64_decode(auth_b64)?;
301    let decoded_str = String::from_utf8(decoded).ok()?;
302    let (user, pass) = decoded_str.split_once(':')?;
303    if user.is_empty() {
304        return None;
305    }
306    Some(RegistryAuth {
307        username: user.to_string(),
308        password: pass.to_string(),
309    })
310}
311
312/// Run a Docker credential helper to get credentials.
313fn resolve_from_credential_helper(helper_name: &str, registry: &str) -> Option<RegistryAuth> {
314    let helper_bin = format!("docker-credential-{}", helper_name);
315    let output = std::process::Command::new(&helper_bin)
316        .arg("get")
317        .stdin(std::process::Stdio::piped())
318        .stdout(std::process::Stdio::piped())
319        .stderr(std::process::Stdio::null())
320        .spawn()
321        .ok()
322        .and_then(|mut child| {
323            use std::io::Write;
324            if let Some(ref mut stdin) = child.stdin {
325                stdin.write_all(registry.as_bytes()).ok();
326            }
327            drop(child.stdin.take()); // Close stdin so helper can proceed
328            let start = std::time::Instant::now();
329            let timeout = std::time::Duration::from_secs(10);
330            loop {
331                match child.try_wait() {
332                    Ok(Some(_)) => return child.wait_with_output().ok(),
333                    Ok(None) if start.elapsed() >= timeout => {
334                        let _ = child.kill();
335                        let _ = child.wait();
336                        tracing::warn!(helper = %helper_bin, "credential helper timed out after {}s", timeout.as_secs());
337                        return None;
338                    }
339                    Ok(None) => std::thread::sleep(std::time::Duration::from_millis(50)),
340                    Err(_) => return None,
341                }
342            }
343        })?;
344
345    if !output.status.success() {
346        return None;
347    }
348
349    #[derive(Deserialize)]
350    struct CredHelperOutput {
351        #[serde(alias = "Username")]
352        username: String,
353        #[serde(alias = "Secret")]
354        secret: String,
355    }
356
357    let parsed: CredHelperOutput = serde_json::from_slice(&output.stdout).ok()?;
358    if parsed.username.is_empty() {
359        return None;
360    }
361    Some(RegistryAuth {
362        username: parsed.username,
363        password: parsed.secret,
364    })
365}
366
367// ---------------------------------------------------------------------------
368// OCI Manifest types (OCI Image Manifest v1)
369// ---------------------------------------------------------------------------
370
371#[derive(Debug, Serialize, Deserialize)]
372#[serde(rename_all = "camelCase")]
373struct OciManifest {
374    schema_version: u32,
375    media_type: String,
376    config: OciDescriptor,
377    layers: Vec<OciDescriptor>,
378    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
379    annotations: HashMap<String, String>,
380}
381
382#[derive(Debug, Serialize, Deserialize)]
383#[serde(rename_all = "camelCase")]
384struct OciDescriptor {
385    media_type: String,
386    digest: String,
387    size: u64,
388    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
389    annotations: HashMap<String, String>,
390}
391
392// ---------------------------------------------------------------------------
393// Internal HTTP helpers
394// ---------------------------------------------------------------------------
395
396/// Attempt to get a bearer token for the given registry+repository scope.
397/// Registries return a 401 with a Www-Authenticate header pointing to a token endpoint.
398fn get_bearer_token(
399    agent: &ureq::Agent,
400    www_authenticate: &str,
401    auth: Option<&RegistryAuth>,
402) -> Result<String, OciError> {
403    // Parse: Bearer realm="...",service="...",scope="..."
404    let realm = extract_auth_param(www_authenticate, "realm");
405    let service = extract_auth_param(www_authenticate, "service");
406    let scope = extract_auth_param(www_authenticate, "scope");
407
408    let realm = realm.ok_or_else(|| OciError::AuthFailed {
409        registry: String::new(),
410        message: format!("missing realm in Www-Authenticate header: {www_authenticate}"),
411    })?;
412
413    let mut url = realm.to_string();
414    let mut params = Vec::new();
415    if let Some(svc) = service {
416        params.push(format!("service={}", svc));
417    }
418    if let Some(sc) = scope {
419        params.push(format!("scope={}", sc));
420    }
421    if !params.is_empty() {
422        url = format!("{}?{}", url, params.join("&"));
423    }
424
425    let mut req = agent.get(&url);
426    if let Some(cred) = auth {
427        req = req.set("Authorization", &cred.basic_auth_header());
428    }
429
430    let resp = req.call().map_err(|e| OciError::AuthFailed {
431        registry: String::new(),
432        message: format!("token request failed: {e}"),
433    })?;
434
435    #[derive(Deserialize)]
436    struct TokenResponse {
437        token: Option<String>,
438        access_token: Option<String>,
439    }
440
441    let body_str = resp.into_string().map_err(|e| OciError::AuthFailed {
442        registry: String::new(),
443        message: format!("cannot read token response body: {e}"),
444    })?;
445    let body: TokenResponse =
446        serde_json::from_str(&body_str).map_err(|e| OciError::AuthFailed {
447            registry: String::new(),
448            message: format!("invalid token response JSON: {e}"),
449        })?;
450
451    body.token
452        .or(body.access_token)
453        .ok_or_else(|| OciError::AuthFailed {
454            registry: String::new(),
455            message: "no token in response".to_string(),
456        })
457}
458
459fn extract_auth_param<'a>(header: &'a str, param: &str) -> Option<&'a str> {
460    let search = format!("{param}=\"");
461    let start = header.find(&search)?;
462    let value_start = start + search.len();
463    let end = header[value_start..].find('"')?;
464    Some(&header[value_start..value_start + end])
465}
466
467/// Make an authenticated request. Handles 401 → token exchange flow.
468fn authenticated_request(
469    agent: &ureq::Agent,
470    method: &str,
471    url: &str,
472    auth: Option<&RegistryAuth>,
473    accept: Option<&str>,
474    content_type: Option<&str>,
475    body: Option<&[u8]>,
476) -> Result<ureq::Response, OciError> {
477    // First attempt — may get 401
478    let mut req = match method {
479        "GET" => agent.get(url),
480        "PUT" => agent.put(url),
481        "POST" => agent.post(url),
482        "HEAD" => agent.head(url),
483        "PATCH" => agent.request("PATCH", url),
484        _ => agent.get(url),
485    };
486
487    if let Some(ct) = content_type {
488        req = req.set("Content-Type", ct);
489    }
490    if let Some(acc) = accept {
491        req = req.set("Accept", acc);
492    }
493    if let Some(cred) = auth {
494        req = req.set("Authorization", &cred.basic_auth_header());
495    }
496
497    let result = if let Some(b) = body {
498        req.send_bytes(b)
499    } else {
500        req.call()
501    };
502
503    match result {
504        Ok(resp) => Ok(resp),
505        Err(ureq::Error::Status(401, resp)) => {
506            // Get the Www-Authenticate header and try token auth
507            let www_auth = resp
508                .header("Www-Authenticate")
509                .or_else(|| resp.header("www-authenticate"))
510                .unwrap_or("")
511                .to_string();
512
513            if www_auth.is_empty() || !www_auth.contains("Bearer") {
514                return Err(OciError::AuthFailed {
515                    registry: url.to_string(),
516                    message: "401 with no Bearer challenge".to_string(),
517                });
518            }
519
520            let token = get_bearer_token(agent, &www_auth, auth)?;
521
522            // Retry with bearer token
523            let mut req2 = match method {
524                "GET" => agent.get(url),
525                "PUT" => agent.put(url),
526                "POST" => agent.post(url),
527                "HEAD" => agent.head(url),
528                "PATCH" => agent.request("PATCH", url),
529                _ => agent.get(url),
530            };
531
532            if let Some(ct) = content_type {
533                req2 = req2.set("Content-Type", ct);
534            }
535            if let Some(acc) = accept {
536                req2 = req2.set("Accept", acc);
537            }
538            req2 = req2.set("Authorization", &format!("Bearer {}", token));
539
540            let result2 = if let Some(b) = body {
541                req2.send_bytes(b)
542            } else {
543                req2.call()
544            };
545
546            result2.map_err(|e| OciError::RequestFailed {
547                message: format!("{e}"),
548            })
549        }
550        Err(ureq::Error::Status(code, _)) => Err(OciError::RequestFailed {
551            message: format!("HTTP {code} from {url}"),
552        }),
553        Err(e) => Err(OciError::RequestFailed {
554            message: format!("{e}"),
555        }),
556    }
557}
558
559// ---------------------------------------------------------------------------
560// Blob helpers
561// ---------------------------------------------------------------------------
562
563fn sha256_digest(data: &[u8]) -> String {
564    format!("sha256:{}", crate::sha256_hex(data))
565}
566
567/// Upload a blob to the registry via the monolithic upload flow.
568/// POST /v2/{name}/blobs/uploads/ → PUT with digest.
569fn upload_blob(
570    agent: &ureq::Agent,
571    oci_ref: &OciReference,
572    auth: Option<&RegistryAuth>,
573    data: &[u8],
574    content_type: &str,
575) -> Result<String, OciError> {
576    let digest = sha256_digest(data);
577
578    // Check if blob already exists (HEAD)
579    let head_url = format!(
580        "{}/{}/blobs/{}",
581        oci_ref.api_base(),
582        oci_ref.repository,
583        digest
584    );
585    let head_result = authenticated_request(agent, "HEAD", &head_url, auth, None, None, None);
586    if head_result.is_ok() {
587        tracing::debug!(digest = %digest, "blob already exists, skipping upload");
588        return Ok(digest);
589    }
590
591    // Start upload
592    let upload_url = format!(
593        "{}/{}/blobs/uploads/",
594        oci_ref.api_base(),
595        oci_ref.repository,
596    );
597
598    let resp = authenticated_request(agent, "POST", &upload_url, auth, None, None, Some(&[]))
599        .map_err(|e| OciError::BlobUploadFailed {
600            digest: digest.clone(),
601            message: format!("upload initiation failed: {e}"),
602        })?;
603
604    let location = resp
605        .header("Location")
606        .ok_or_else(|| OciError::BlobUploadFailed {
607            digest: digest.clone(),
608            message: "no Location header in upload response".to_string(),
609        })?
610        .to_string();
611
612    // Complete upload with PUT — monolithic upload
613    let sep = if location.contains('?') { "&" } else { "?" };
614    let put_url = format!("{location}{sep}digest={digest}");
615
616    // For the PUT, we need to handle the case where location is a relative URL
617    let put_url = if put_url.starts_with("http://") || put_url.starts_with("https://") {
618        put_url
619    } else {
620        format!(
621            "{}{}",
622            oci_ref
623                .api_base()
624                .trim_end_matches("/v2")
625                .trim_end_matches("/v2/"),
626            put_url
627        )
628    };
629
630    authenticated_request(
631        agent,
632        "PUT",
633        &put_url,
634        auth,
635        None,
636        Some(content_type),
637        Some(data),
638    )
639    .map_err(|e| OciError::BlobUploadFailed {
640        digest: digest.clone(),
641        message: format!("blob PUT failed: {e}"),
642    })?;
643
644    tracing::debug!(digest = %digest, size = data.len(), "blob uploaded");
645    Ok(digest)
646}
647
648// ---------------------------------------------------------------------------
649// Archive helpers
650// ---------------------------------------------------------------------------
651
652/// Create a tar.gz archive of a directory's contents.
653pub fn create_tar_gz(dir: &Path) -> Result<Vec<u8>, OciError> {
654    let buf = Vec::new();
655    let encoder = flate2::write::GzEncoder::new(buf, flate2::Compression::default());
656    let mut archive = tar::Builder::new(encoder);
657
658    // Add directory contents relative to dir
659    add_dir_to_tar(&mut archive, dir, dir)?;
660
661    let encoder = archive.into_inner().map_err(|e| OciError::ArchiveError {
662        message: format!("tar finalization failed: {e}"),
663    })?;
664    let compressed = encoder.finish().map_err(|e| OciError::ArchiveError {
665        message: format!("gzip finalization failed: {e}"),
666    })?;
667    Ok(compressed)
668}
669
670fn add_dir_to_tar<W: std::io::Write>(
671    archive: &mut tar::Builder<W>,
672    dir: &Path,
673    root: &Path,
674) -> Result<(), OciError> {
675    let entries = std::fs::read_dir(dir).map_err(|e| OciError::ArchiveError {
676        message: format!("cannot read directory {}: {e}", dir.display()),
677    })?;
678
679    for entry in entries {
680        let entry = entry.map_err(|e| OciError::ArchiveError {
681            message: format!("directory entry error: {e}"),
682        })?;
683        let file_type = entry.file_type().map_err(|e| OciError::ArchiveError {
684            message: format!("file type error: {e}"),
685        })?;
686        // Skip symlinks — prevents including content from outside the module directory
687        if file_type.is_symlink() {
688            continue;
689        }
690        let path = entry.path();
691        let relative = path
692            .strip_prefix(root)
693            .map_err(|e| OciError::ArchiveError {
694                message: format!("path prefix error: {e}"),
695            })?;
696
697        if file_type.is_dir() {
698            archive
699                .append_dir(relative, &path)
700                .map_err(|e| OciError::ArchiveError {
701                    message: format!("tar append dir failed: {e}"),
702                })?;
703            add_dir_to_tar(archive, &path, root)?;
704        } else {
705            archive
706                .append_path_with_name(&path, relative)
707                .map_err(|e| OciError::ArchiveError {
708                    message: format!("tar append file failed: {e}"),
709                })?;
710        }
711    }
712    Ok(())
713}
714
715/// Extract a tar.gz archive into a directory.
716pub fn extract_tar_gz(data: &[u8], output_dir: &Path) -> Result<(), OciError> {
717    let decoder = flate2::read::GzDecoder::new(data);
718    let mut archive = tar::Archive::new(decoder);
719
720    std::fs::create_dir_all(output_dir).map_err(|e| OciError::ArchiveError {
721        message: format!("cannot create output directory: {e}"),
722    })?;
723
724    // Extract entries individually to validate against symlink attacks.
725    // The tar crate rejects `..` and absolute paths by default (since 0.4.26),
726    // but symlinks can still point outside the output directory.
727    let canonical_output = output_dir
728        .canonicalize()
729        .map_err(|e| OciError::ArchiveError {
730            message: format!("cannot canonicalize output directory: {e}"),
731        })?;
732
733    for entry in archive.entries().map_err(|e| OciError::ArchiveError {
734        message: format!("tar iteration failed: {e}"),
735    })? {
736        let mut entry = entry.map_err(|e| OciError::ArchiveError {
737            message: format!("tar entry read failed: {e}"),
738        })?;
739
740        // Skip symlinks — prevents symlink-based path traversal
741        if entry.header().entry_type().is_symlink() || entry.header().entry_type().is_hard_link() {
742            let path = entry.path().unwrap_or_default();
743            tracing::warn!(path = %path.display(), "skipping symlink/hardlink in OCI archive");
744            continue;
745        }
746
747        entry
748            .unpack_in(&canonical_output)
749            .map_err(|e| OciError::ArchiveError {
750                message: format!("tar entry extraction failed: {e}"),
751            })?;
752    }
753
754    Ok(())
755}
756
757// ---------------------------------------------------------------------------
758// Push
759// ---------------------------------------------------------------------------
760
761/// Push a module directory as an OCI artifact.
762///
763/// Reads `module.yaml` from `dir`, serializes it as the config blob, and
764/// tars+gzips the directory contents as a single layer. Pushes to the
765/// registry specified by `artifact_ref`.
766///
767/// Returns the pushed manifest digest.
768pub fn push_module(
769    dir: &Path,
770    artifact_ref: &str,
771    platform: Option<&str>,
772    printer: Option<&Printer>,
773) -> Result<String, OciError> {
774    let oci_ref = OciReference::parse(artifact_ref)?;
775    let auth = RegistryAuth::resolve(&oci_ref.registry);
776    let agent = ureq::AgentBuilder::new()
777        .timeout(std::time::Duration::from_secs(300))
778        .build();
779    let spinner = printer.map(|p| p.spinner(&format!("Pushing module to {artifact_ref}...")));
780    let (digest, _size) = push_module_inner(&agent, dir, &oci_ref, auth.as_ref(), platform)?;
781    if let Some(s) = spinner {
782        s.finish_and_clear();
783    }
784    Ok(digest)
785}
786
787/// Inner push logic shared by single-platform and multi-platform push.
788/// Returns (manifest_digest, manifest_size_bytes).
789fn push_module_inner(
790    agent: &ureq::Agent,
791    dir: &Path,
792    oci_ref: &OciReference,
793    auth: Option<&RegistryAuth>,
794    platform: Option<&str>,
795) -> Result<(String, u64), OciError> {
796    // Read module.yaml
797    let module_yaml_path = dir.join("module.yaml");
798    if !module_yaml_path.exists() {
799        return Err(OciError::ModuleYamlNotFound {
800            dir: dir.to_path_buf(),
801        });
802    }
803    let module_yaml = std::fs::read_to_string(&module_yaml_path)?;
804
805    // Serialize config blob as JSON (module.yaml content wrapped in JSON)
806    let config_blob = serde_json::to_vec(&serde_json::json!({
807        "moduleYaml": module_yaml,
808    }))?;
809
810    // Create layer archive
811    let layer_data = create_tar_gz(dir)?;
812
813    // Build platform annotation
814    let platform_str = platform.map(String::from).unwrap_or_else(current_platform);
815
816    // Upload config blob
817    let config_digest = upload_blob(agent, oci_ref, auth, &config_blob, MEDIA_TYPE_MODULE_CONFIG)?;
818
819    // Upload layer blob
820    let layer_digest = upload_blob(agent, oci_ref, auth, &layer_data, MEDIA_TYPE_MODULE_LAYER)?;
821
822    // Build manifest
823    let mut annotations = HashMap::new();
824    annotations.insert("cfgd.io/platform".to_string(), platform_str);
825    annotations.insert(
826        "org.opencontainers.image.created".to_string(),
827        crate::utc_now_iso8601(),
828    );
829
830    let manifest = OciManifest {
831        schema_version: 2,
832        media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
833        config: OciDescriptor {
834            media_type: MEDIA_TYPE_MODULE_CONFIG.to_string(),
835            digest: config_digest,
836            size: config_blob.len() as u64,
837            annotations: HashMap::new(),
838        },
839        layers: vec![OciDescriptor {
840            media_type: MEDIA_TYPE_MODULE_LAYER.to_string(),
841            digest: layer_digest,
842            size: layer_data.len() as u64,
843            annotations: HashMap::new(),
844        }],
845        annotations,
846    };
847
848    let manifest_json = serde_json::to_vec(&manifest)?;
849
850    // Push manifest
851    let manifest_url = format!(
852        "{}/{}/manifests/{}",
853        oci_ref.api_base(),
854        oci_ref.repository,
855        oci_ref.reference_str(),
856    );
857
858    authenticated_request(
859        agent,
860        "PUT",
861        &manifest_url,
862        auth,
863        None,
864        Some(MEDIA_TYPE_OCI_MANIFEST),
865        Some(&manifest_json),
866    )
867    .map_err(|e| OciError::ManifestPushFailed {
868        message: format!("{e}"),
869    })?;
870
871    let manifest_size = manifest_json.len() as u64;
872    let manifest_digest = sha256_digest(&manifest_json);
873    tracing::info!(
874        reference = %oci_ref,
875        digest = %manifest_digest,
876        "module pushed"
877    );
878
879    Ok((manifest_digest, manifest_size))
880}
881
882// ---------------------------------------------------------------------------
883// Multi-platform index
884// ---------------------------------------------------------------------------
885
886const MEDIA_TYPE_OCI_INDEX: &str = "application/vnd.oci.image.index.v1+json";
887
888#[derive(Debug, Serialize, Deserialize)]
889#[serde(rename_all = "camelCase")]
890struct OciIndex {
891    schema_version: u32,
892    media_type: String,
893    manifests: Vec<OciPlatformManifest>,
894}
895
896#[derive(Debug, Serialize, Deserialize)]
897#[serde(rename_all = "camelCase")]
898struct OciPlatformManifest {
899    media_type: String,
900    digest: String,
901    size: u64,
902    platform: OciPlatform,
903}
904
905#[derive(Debug, Serialize, Deserialize)]
906struct OciPlatform {
907    os: String,
908    architecture: String,
909}
910
911/// Map Rust arch names to OCI architecture names.
912pub fn rust_arch_to_oci(arch: &str) -> &str {
913    match arch {
914        "x86_64" => "amd64",
915        "aarch64" => "arm64",
916        "arm" => "arm",
917        "s390x" => "s390x",
918        "powerpc64" => "ppc64le",
919        other => other,
920    }
921}
922
923/// Return the current platform in OCI format (os/arch).
924pub fn current_platform() -> String {
925    format!(
926        "{}/{}",
927        std::env::consts::OS,
928        rust_arch_to_oci(std::env::consts::ARCH)
929    )
930}
931
932/// Parse "os/arch" (e.g. "linux/amd64") into (os, arch).
933pub fn parse_platform_target(target: &str) -> Result<(&str, &str), OciError> {
934    target.split_once('/').ok_or_else(|| OciError::BuildError {
935        message: format!(
936            "invalid platform target '{target}' — expected os/arch (e.g. linux/amd64)"
937        ),
938    })
939}
940
941/// Push a module for multiple platforms, creating an OCI index (manifest list).
942///
943/// Each `builds` entry is `(build_dir, platform)` where platform is "os/arch".
944/// Pushes each platform-specific manifest, then pushes the index.
945pub fn push_module_multiplatform(
946    builds: &[(&Path, &str)],
947    artifact_ref: &str,
948    printer: Option<&Printer>,
949) -> Result<String, OciError> {
950    let oci_ref = OciReference::parse(artifact_ref)?;
951    let auth = RegistryAuth::resolve(&oci_ref.registry);
952    let agent = ureq::AgentBuilder::new()
953        .timeout(std::time::Duration::from_secs(300))
954        .build();
955
956    let spinner = printer.map(|p| {
957        p.spinner(&format!(
958            "Pushing multi-platform module to {artifact_ref}..."
959        ))
960    });
961
962    let mut platform_manifests = Vec::new();
963
964    for (dir, platform) in builds {
965        let (os, arch) = parse_platform_target(platform)?;
966
967        // Push each platform as its own tagged manifest
968        let platform_tag = format!("{}-{}", oci_ref.reference_str(), platform.replace('/', "-"));
969        let platform_ref = OciReference {
970            registry: oci_ref.registry.clone(),
971            repository: oci_ref.repository.clone(),
972            reference: ReferenceKind::Tag(platform_tag),
973        };
974
975        let (digest, size) =
976            push_module_inner(&agent, dir, &platform_ref, auth.as_ref(), Some(platform))?;
977
978        platform_manifests.push(OciPlatformManifest {
979            media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
980            digest,
981            size,
982            platform: OciPlatform {
983                os: os.to_string(),
984                architecture: arch.to_string(),
985            },
986        });
987    }
988
989    // Build and push the index
990    let index = OciIndex {
991        schema_version: 2,
992        media_type: MEDIA_TYPE_OCI_INDEX.to_string(),
993        manifests: platform_manifests,
994    };
995    let index_json = serde_json::to_vec(&index)?;
996
997    let index_url = format!(
998        "{}/{}/manifests/{}",
999        oci_ref.api_base(),
1000        oci_ref.repository,
1001        oci_ref.reference_str(),
1002    );
1003
1004    authenticated_request(
1005        &agent,
1006        "PUT",
1007        &index_url,
1008        auth.as_ref(),
1009        None,
1010        Some(MEDIA_TYPE_OCI_INDEX),
1011        Some(&index_json),
1012    )
1013    .map_err(|e| OciError::ManifestPushFailed {
1014        message: format!("index push failed: {e}"),
1015    })?;
1016
1017    let index_digest = sha256_digest(&index_json);
1018
1019    if let Some(s) = spinner {
1020        s.finish_and_clear();
1021    }
1022
1023    tracing::info!(
1024        reference = %oci_ref,
1025        digest = %index_digest,
1026        platforms = builds.len(),
1027        "multi-platform module pushed"
1028    );
1029
1030    Ok(index_digest)
1031}
1032
1033// ---------------------------------------------------------------------------
1034// Build
1035// ---------------------------------------------------------------------------
1036
1037/// Detect which container runtime is available (docker or podman).
1038pub fn detect_container_runtime() -> Option<&'static str> {
1039    if crate::command_available("docker") {
1040        Some("docker")
1041    } else if crate::command_available("podman") {
1042        Some("podman")
1043    } else {
1044        None
1045    }
1046}
1047
1048/// Detect the package manager install command based on the base image name.
1049fn detect_pkg_install_cmd(base_image: &str) -> &'static str {
1050    let lower = base_image.to_ascii_lowercase();
1051    if lower.starts_with("alpine") || lower.contains("/alpine") {
1052        "apk add --no-cache"
1053    } else if lower.starts_with("fedora")
1054        || lower.contains("/fedora")
1055        || lower.starts_with("rockylinux")
1056        || lower.contains("/rockylinux")
1057        || lower.starts_with("almalinux")
1058        || lower.contains("/almalinux")
1059    {
1060        "dnf install -y"
1061    } else if lower.starts_with("centos") || lower.contains("/centos") {
1062        "yum install -y"
1063    } else if lower.starts_with("archlinux") || lower.contains("/archlinux") {
1064        "pacman -Sy --noconfirm"
1065    } else {
1066        // Debian, Ubuntu, and default
1067        "apt-get update && apt-get install -y"
1068    }
1069}
1070
1071/// Generate a Dockerfile for building a module in an isolated container.
1072fn build_dockerfile(base_image: &str, packages: &[&str]) -> String {
1073    let mut lines = vec![format!("FROM {base_image}")];
1074    if !packages.is_empty() {
1075        let pkg_list = packages.join(" ");
1076        let install_cmd = detect_pkg_install_cmd(base_image);
1077        if install_cmd.starts_with("apt-get") {
1078            lines.push(format!(
1079                "RUN {install_cmd} {pkg_list} && rm -rf /var/lib/apt/lists/*"
1080            ));
1081        } else {
1082            lines.push(format!("RUN {install_cmd} {pkg_list}"));
1083        }
1084    }
1085    lines.push("WORKDIR /build".to_string());
1086    lines.push("COPY . /build/".to_string());
1087    lines.join("\n")
1088}
1089
1090/// Build a module directory into an OCI-ready artifact using a container.
1091///
1092/// Creates a Dockerfile, builds a container image, copies out the installed
1093/// files, and packages them as a tar.gz layer ready for `push_module()`.
1094///
1095/// Returns the path to the build output directory.
1096pub fn build_module(
1097    dir: &Path,
1098    target_platform: Option<&str>,
1099    base_image: Option<&str>,
1100) -> Result<std::path::PathBuf, OciError> {
1101    let module_yaml_path = dir.join("module.yaml");
1102    if !module_yaml_path.exists() {
1103        return Err(OciError::ModuleYamlNotFound {
1104            dir: dir.to_path_buf(),
1105        });
1106    }
1107
1108    let runtime = detect_container_runtime().ok_or(OciError::ToolNotFound {
1109        tool: "docker or podman".to_string(),
1110    })?;
1111
1112    let module_yaml = std::fs::read_to_string(&module_yaml_path)?;
1113    let module_doc =
1114        crate::config::parse_module(&module_yaml).map_err(|e| OciError::BuildError {
1115            message: format!("invalid module.yaml: {e}"),
1116        })?;
1117
1118    // Extract package names from module spec
1119    let pkg_names: Vec<String> = module_doc
1120        .spec
1121        .packages
1122        .iter()
1123        .map(|p| p.name.clone())
1124        .collect();
1125    let packages: Vec<&str> = pkg_names.iter().map(|s| s.as_str()).collect();
1126
1127    let base = base_image.unwrap_or("ubuntu:22.04");
1128    let dockerfile_content = build_dockerfile(base, &packages);
1129
1130    // Copy module directory into temp build context, then write Dockerfile
1131    // (write after copy so a user's Dockerfile doesn't overwrite the generated one)
1132    let build_dir = tempfile::tempdir().map_err(|e| OciError::BuildError {
1133        message: format!("cannot create temp dir: {e}"),
1134    })?;
1135    crate::copy_dir_recursive(dir, build_dir.path())?;
1136    std::fs::write(build_dir.path().join("Dockerfile"), &dockerfile_content)?;
1137
1138    // Build the container image
1139    let tag = format!(
1140        "cfgd-build-{}:{}",
1141        module_doc.metadata.name,
1142        std::process::id(),
1143    );
1144    let container_name = format!(
1145        "cfgd-build-{}-{}",
1146        std::process::id(),
1147        crate::utc_now_iso8601().replace([':', '-', 'T', 'Z'], ""),
1148    );
1149
1150    let mut build_cmd = std::process::Command::new(runtime);
1151    build_cmd.arg("build").arg("-t").arg(&tag);
1152
1153    if let Some(platform) = target_platform {
1154        build_cmd.arg("--platform").arg(platform);
1155    }
1156
1157    build_cmd
1158        .arg("-f")
1159        .arg(build_dir.path().join("Dockerfile"))
1160        .arg(build_dir.path());
1161
1162    let build_output = build_cmd.output().map_err(|e| OciError::BuildError {
1163        message: format!("{runtime} build failed: {e}"),
1164    })?;
1165
1166    if !build_output.status.success() {
1167        return Err(OciError::BuildError {
1168            message: format!(
1169                "{runtime} build failed:\n{}",
1170                crate::stderr_lossy_trimmed(&build_output)
1171            ),
1172        });
1173    }
1174
1175    // Create container and copy build output
1176    let output_dir = tempfile::tempdir().map_err(|e| OciError::BuildError {
1177        message: format!("cannot create output dir: {e}"),
1178    })?;
1179
1180    let create_output = std::process::Command::new(runtime)
1181        .args(["create", "--name", &container_name, &tag])
1182        .output()
1183        .map_err(|e| OciError::BuildError {
1184            message: format!("container create failed: {e}"),
1185        })?;
1186
1187    if !create_output.status.success() {
1188        return Err(OciError::BuildError {
1189            message: format!(
1190                "container create failed: {}",
1191                crate::stderr_lossy_trimmed(&create_output)
1192            ),
1193        });
1194    }
1195
1196    // Copy /build directory out of the container
1197    let cp_output = std::process::Command::new(runtime)
1198        .args([
1199            "cp",
1200            &format!("{container_name}:/build/."),
1201            &output_dir.path().display().to_string(),
1202        ])
1203        .output()
1204        .map_err(|e| OciError::BuildError {
1205            message: format!("container cp failed: {e}"),
1206        })?;
1207
1208    // Cleanup container and image (best effort)
1209    let _ = std::process::Command::new(runtime)
1210        .args(["rm", "-f", &container_name])
1211        .output();
1212    let _ = std::process::Command::new(runtime)
1213        .args(["rmi", "-f", &tag])
1214        .output();
1215
1216    if !cp_output.status.success() {
1217        return Err(OciError::BuildError {
1218            message: format!(
1219                "container cp failed: {}",
1220                crate::stderr_lossy_trimmed(&cp_output)
1221            ),
1222        });
1223    }
1224
1225    let out = output_dir.path().to_path_buf();
1226    let _keep = output_dir.keep();
1227    tracing::info!(output = %out.display(), "module built");
1228    Ok(out)
1229}
1230
1231// ---------------------------------------------------------------------------
1232// Signing (cosign)
1233// ---------------------------------------------------------------------------
1234
1235/// Sign an OCI artifact with cosign.
1236///
1237/// If `key_path` is Some, uses `cosign sign --key <path>`.
1238/// If `key_path` is None, uses keyless signing (Fulcio/Rekor via OIDC).
1239pub fn sign_artifact(artifact_ref: &str, key_path: Option<&str>) -> Result<(), OciError> {
1240    if !crate::command_available("cosign") {
1241        return Err(OciError::ToolNotFound {
1242            tool: "cosign".to_string(),
1243        });
1244    }
1245
1246    let mut cmd = std::process::Command::new("cosign");
1247    cmd.arg("sign");
1248
1249    if let Some(key) = key_path {
1250        cmd.arg("--key").arg(key);
1251    } else {
1252        cmd.arg("--yes");
1253    }
1254
1255    cmd.arg(artifact_ref);
1256
1257    let output = cmd.output().map_err(|e| OciError::SigningError {
1258        message: format!("failed to run cosign: {e}"),
1259    })?;
1260
1261    if !output.status.success() {
1262        return Err(OciError::SigningError {
1263            message: format!(
1264                "cosign sign failed: {}",
1265                crate::stderr_lossy_trimmed(&output)
1266            ),
1267        });
1268    }
1269
1270    tracing::info!(reference = artifact_ref, "artifact signed with cosign");
1271    Ok(())
1272}
1273
1274/// Options for cosign verification (signature or attestation).
1275pub struct VerifyOptions<'a> {
1276    /// Path to cosign public key for static key verification.
1277    pub key: Option<&'a str>,
1278    /// Certificate identity regexp for keyless verification.
1279    pub identity: Option<&'a str>,
1280    /// Certificate OIDC issuer regexp for keyless verification.
1281    pub issuer: Option<&'a str>,
1282}
1283
1284/// Validate that keyless verification has at least one identity constraint.
1285fn validate_verify_options(opts: &VerifyOptions<'_>) -> Result<(), OciError> {
1286    if opts.key.is_none() && opts.identity.is_none() && opts.issuer.is_none() {
1287        return Err(OciError::VerificationFailed {
1288            reference: String::new(),
1289            message: "keyless verification requires identity or issuer constraint (use --key, or provide VerifyOptions.identity/issuer)".to_string(),
1290        });
1291    }
1292    Ok(())
1293}
1294
1295/// Apply verification args to a cosign command.
1296fn apply_verify_args(cmd: &mut std::process::Command, opts: &VerifyOptions<'_>) {
1297    if let Some(key) = opts.key {
1298        cmd.arg("--key").arg(key);
1299    } else {
1300        let identity = opts.identity.unwrap_or(".*");
1301        let issuer = opts.issuer.unwrap_or(".*");
1302        cmd.arg("--certificate-identity-regexp").arg(identity);
1303        cmd.arg("--certificate-oidc-issuer-regexp").arg(issuer);
1304    }
1305}
1306
1307/// Verify the cosign signature on an OCI artifact.
1308///
1309/// Uses `cosign verify --key <path>` for static key, or keyless verification
1310/// with certificate identity/issuer constraints from `VerifyOptions`.
1311pub fn verify_signature(artifact_ref: &str, opts: &VerifyOptions<'_>) -> Result<(), OciError> {
1312    validate_verify_options(opts)?;
1313
1314    if !crate::command_available("cosign") {
1315        return Err(OciError::ToolNotFound {
1316            tool: "cosign".to_string(),
1317        });
1318    }
1319
1320    let mut cmd = std::process::Command::new("cosign");
1321    cmd.arg("verify");
1322    apply_verify_args(&mut cmd, opts);
1323    cmd.arg(artifact_ref);
1324
1325    let output = cmd.output().map_err(|e| OciError::VerificationFailed {
1326        reference: artifact_ref.to_string(),
1327        message: format!("failed to run cosign: {e}"),
1328    })?;
1329
1330    if !output.status.success() {
1331        return Err(OciError::VerificationFailed {
1332            reference: artifact_ref.to_string(),
1333            message: format!(
1334                "cosign verify failed: {}",
1335                crate::stderr_lossy_trimmed(&output)
1336            ),
1337        });
1338    }
1339
1340    tracing::info!(reference = artifact_ref, "signature verified");
1341    Ok(())
1342}
1343
1344// ---------------------------------------------------------------------------
1345// Attestations (SLSA provenance / in-toto)
1346// ---------------------------------------------------------------------------
1347
1348/// Generate a SLSA v1 provenance predicate JSON for a module artifact.
1349pub fn generate_slsa_provenance(
1350    artifact_ref: &str,
1351    digest: &str,
1352    source_repo: &str,
1353    source_commit: &str,
1354) -> Result<String, OciError> {
1355    let now = crate::utc_now_iso8601();
1356    serde_json::to_string_pretty(&serde_json::json!({
1357        "_type": "https://in-toto.io/Statement/v1",
1358        "predicateType": "https://slsa.dev/provenance/v1",
1359        "subject": [{
1360            "name": artifact_ref,
1361            "digest": {
1362                "sha256": digest.strip_prefix("sha256:").unwrap_or(digest),
1363            }
1364        }],
1365        "predicate": {
1366            "buildDefinition": {
1367                "buildType": "https://cfgd.io/ModuleBuild/v1",
1368                "externalParameters": {
1369                    "source": {
1370                        "uri": source_repo,
1371                        "digest": { "gitCommit": source_commit },
1372                    }
1373                },
1374            },
1375            "runDetails": {
1376                "builder": {
1377                    "id": "https://cfgd.io/builder/v1",
1378                },
1379                "metadata": {
1380                    "invocationId": &now,
1381                    "startedOn": &now,
1382                }
1383            }
1384        }
1385    }))
1386    .map_err(|e| OciError::AttestationError {
1387        message: format!("failed to serialize SLSA provenance: {e}"),
1388    })
1389}
1390
1391/// Attach an in-toto attestation to an OCI artifact using cosign.
1392pub fn attach_attestation(
1393    artifact_ref: &str,
1394    attestation_path: &str,
1395    key_path: Option<&str>,
1396) -> Result<(), OciError> {
1397    if !crate::command_available("cosign") {
1398        return Err(OciError::ToolNotFound {
1399            tool: "cosign".to_string(),
1400        });
1401    }
1402
1403    let mut cmd = std::process::Command::new("cosign");
1404    cmd.arg("attest");
1405
1406    if let Some(key) = key_path {
1407        cmd.arg("--key").arg(key);
1408    } else {
1409        cmd.arg("--yes");
1410    }
1411
1412    cmd.arg("--predicate")
1413        .arg(attestation_path)
1414        .arg("--type")
1415        .arg("slsaprovenance")
1416        .arg(artifact_ref);
1417
1418    let output = cmd.output().map_err(|e| OciError::AttestationError {
1419        message: format!("failed to run cosign attest: {e}"),
1420    })?;
1421
1422    if !output.status.success() {
1423        return Err(OciError::AttestationError {
1424            message: format!(
1425                "cosign attest failed: {}",
1426                crate::stderr_lossy_trimmed(&output)
1427            ),
1428        });
1429    }
1430
1431    tracing::info!(reference = artifact_ref, "attestation attached");
1432    Ok(())
1433}
1434
1435/// Verify an in-toto attestation on an OCI artifact.
1436pub fn verify_attestation(
1437    artifact_ref: &str,
1438    predicate_type: &str,
1439    opts: &VerifyOptions<'_>,
1440) -> Result<(), OciError> {
1441    validate_verify_options(opts)?;
1442
1443    if !crate::command_available("cosign") {
1444        return Err(OciError::ToolNotFound {
1445            tool: "cosign".to_string(),
1446        });
1447    }
1448
1449    let mut cmd = std::process::Command::new("cosign");
1450    cmd.arg("verify-attestation");
1451    apply_verify_args(&mut cmd, opts);
1452    cmd.arg("--type").arg(predicate_type).arg(artifact_ref);
1453
1454    let output = cmd.output().map_err(|e| OciError::AttestationError {
1455        message: format!("failed to run cosign verify-attestation: {e}"),
1456    })?;
1457
1458    if !output.status.success() {
1459        return Err(OciError::AttestationError {
1460            message: format!(
1461                "attestation verification failed: {}",
1462                crate::stderr_lossy_trimmed(&output)
1463            ),
1464        });
1465    }
1466
1467    tracing::info!(reference = artifact_ref, "attestation verified");
1468    Ok(())
1469}
1470
1471// ---------------------------------------------------------------------------
1472// Pull
1473// ---------------------------------------------------------------------------
1474
1475/// Pull a module from an OCI registry and extract it to `output_dir`.
1476///
1477/// If `require_signature` is true, checks for a cosign signature tag
1478/// (`<tag>.sig` or `sha256-<hash>.sig`) and returns an error if not found.
1479pub fn pull_module(
1480    artifact_ref: &str,
1481    output_dir: &Path,
1482    require_signature: bool,
1483    printer: Option<&Printer>,
1484) -> Result<(), OciError> {
1485    let oci_ref = OciReference::parse(artifact_ref)?;
1486    let auth = RegistryAuth::resolve(&oci_ref.registry);
1487    let agent = ureq::AgentBuilder::new()
1488        .timeout(std::time::Duration::from_secs(300))
1489        .build();
1490
1491    let spinner = printer.map(|p| p.spinner(&format!("Pulling module from {artifact_ref}...")));
1492
1493    // If signature required, check for cosign signature tag
1494    if require_signature {
1495        check_signature_exists(&agent, &oci_ref, auth.as_ref())?;
1496    }
1497
1498    // Pull manifest
1499    let manifest_url = format!(
1500        "{}/{}/manifests/{}",
1501        oci_ref.api_base(),
1502        oci_ref.repository,
1503        oci_ref.reference_str(),
1504    );
1505
1506    let resp = authenticated_request(
1507        &agent,
1508        "GET",
1509        &manifest_url,
1510        auth.as_ref(),
1511        Some(MEDIA_TYPE_OCI_MANIFEST),
1512        None,
1513        None,
1514    )
1515    .map_err(|e| OciError::ManifestNotFound {
1516        reference: format!("{}: {e}", oci_ref),
1517    })?;
1518
1519    let manifest_body = resp.into_string().map_err(|e| OciError::RequestFailed {
1520        message: format!("cannot read manifest body: {e}"),
1521    })?;
1522    let manifest: OciManifest =
1523        serde_json::from_str(&manifest_body).map_err(|e| OciError::RequestFailed {
1524            message: format!("invalid manifest JSON: {e}"),
1525        })?;
1526
1527    // Find our layer
1528    let layer = manifest
1529        .layers
1530        .first()
1531        .ok_or_else(|| OciError::RequestFailed {
1532            message: "manifest has no layers".to_string(),
1533        })?;
1534
1535    // Download layer blob
1536    let blob_url = format!(
1537        "{}/{}/blobs/{}",
1538        oci_ref.api_base(),
1539        oci_ref.repository,
1540        layer.digest,
1541    );
1542
1543    let resp = authenticated_request(
1544        &agent,
1545        "GET",
1546        &blob_url,
1547        auth.as_ref(),
1548        Some("application/octet-stream"),
1549        None,
1550        None,
1551    )
1552    .map_err(|e| OciError::BlobNotFound {
1553        digest: format!("{}: {e}", layer.digest),
1554    })?;
1555
1556    // Read blob data (cap at 512 MB to prevent OOM from malicious manifests)
1557    const MAX_BLOB_SIZE: u64 = 512 * 1024 * 1024;
1558    if layer.size > MAX_BLOB_SIZE {
1559        return Err(OciError::RequestFailed {
1560            message: format!(
1561                "layer size {} exceeds maximum allowed size ({} bytes)",
1562                layer.size, MAX_BLOB_SIZE
1563            ),
1564        });
1565    }
1566    let mut blob_data = Vec::with_capacity(layer.size as usize);
1567    resp.into_reader()
1568        .take(MAX_BLOB_SIZE + 1024)
1569        .read_to_end(&mut blob_data)?;
1570
1571    // Verify digest
1572    let actual_digest = sha256_digest(&blob_data);
1573    if actual_digest != layer.digest {
1574        return Err(OciError::RequestFailed {
1575            message: format!(
1576                "layer digest mismatch: expected {}, got {}",
1577                layer.digest, actual_digest
1578            ),
1579        });
1580    }
1581
1582    // Extract
1583    extract_tar_gz(&blob_data, output_dir)?;
1584
1585    if let Some(s) = spinner {
1586        s.finish_and_clear();
1587    }
1588
1589    tracing::info!(
1590        reference = %oci_ref,
1591        output = %output_dir.display(),
1592        "module pulled"
1593    );
1594
1595    Ok(())
1596}
1597
1598/// Check if a cosign-style signature exists for the given reference.
1599/// Cosign stores signatures at tag `<tag>.sig` or `sha256-<hash>.sig`.
1600fn check_signature_exists(
1601    agent: &ureq::Agent,
1602    oci_ref: &OciReference,
1603    auth: Option<&RegistryAuth>,
1604) -> Result<(), OciError> {
1605    let sig_tag = match &oci_ref.reference {
1606        ReferenceKind::Tag(tag) => format!("{tag}.sig"),
1607        ReferenceKind::Digest(digest) => {
1608            // sha256:abc... → sha256-abc....sig
1609            digest.replace(':', "-") + ".sig"
1610        }
1611    };
1612
1613    let sig_url = format!(
1614        "{}/{}/manifests/{}",
1615        oci_ref.api_base(),
1616        oci_ref.repository,
1617        sig_tag,
1618    );
1619
1620    let result = authenticated_request(agent, "HEAD", &sig_url, auth, None, None, None);
1621
1622    match result {
1623        Ok(_) => Ok(()),
1624        Err(_) => Err(OciError::SignatureRequired {
1625            reference: oci_ref.to_string(),
1626        }),
1627    }
1628}
1629
1630// ---------------------------------------------------------------------------
1631// Base64 helpers (no external dependency needed for this)
1632// ---------------------------------------------------------------------------
1633
1634fn base64_encode(data: &[u8]) -> String {
1635    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1636    let mut result = String::new();
1637    for chunk in data.chunks(3) {
1638        let b0 = chunk[0] as u32;
1639        let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
1640        let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
1641        let triple = (b0 << 16) | (b1 << 8) | b2;
1642
1643        result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
1644        result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
1645
1646        if chunk.len() > 1 {
1647            result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
1648        } else {
1649            result.push('=');
1650        }
1651
1652        if chunk.len() > 2 {
1653            result.push(CHARS[(triple & 0x3F) as usize] as char);
1654        } else {
1655            result.push('=');
1656        }
1657    }
1658    result
1659}
1660
1661fn base64_decode(input: &str) -> Option<Vec<u8>> {
1662    fn char_val(c: u8) -> Option<u8> {
1663        match c {
1664            b'A'..=b'Z' => Some(c - b'A'),
1665            b'a'..=b'z' => Some(c - b'a' + 26),
1666            b'0'..=b'9' => Some(c - b'0' + 52),
1667            b'+' => Some(62),
1668            b'/' => Some(63),
1669            b'=' => Some(0),
1670            _ => None,
1671        }
1672    }
1673
1674    let input = input.trim();
1675    if input.is_empty() {
1676        return Some(Vec::new());
1677    }
1678
1679    let bytes = input.as_bytes();
1680    if !bytes.len().is_multiple_of(4) {
1681        return None;
1682    }
1683
1684    let mut result = Vec::with_capacity(bytes.len() * 3 / 4);
1685    for chunk in bytes.chunks(4) {
1686        let a = char_val(chunk[0])?;
1687        let b = char_val(chunk[1])?;
1688        let c = char_val(chunk[2])?;
1689        let d = char_val(chunk[3])?;
1690
1691        let triple = ((a as u32) << 18) | ((b as u32) << 12) | ((c as u32) << 6) | (d as u32);
1692
1693        result.push(((triple >> 16) & 0xFF) as u8);
1694        if chunk[2] != b'=' {
1695            result.push(((triple >> 8) & 0xFF) as u8);
1696        }
1697        if chunk[3] != b'=' {
1698            result.push((triple & 0xFF) as u8);
1699        }
1700    }
1701
1702    Some(result)
1703}
1704
1705// ---------------------------------------------------------------------------
1706// Tests
1707// ---------------------------------------------------------------------------
1708
1709#[cfg(test)]
1710mod tests {
1711    use super::*;
1712
1713    // --- OCI Reference parsing ---
1714
1715    #[test]
1716    fn parse_full_reference_with_tag() {
1717        let r = OciReference::parse("ghcr.io/myorg/mymodule:v1.0.0").unwrap();
1718        assert_eq!(r.registry, "ghcr.io");
1719        assert_eq!(r.repository, "myorg/mymodule");
1720        assert_eq!(r.reference, ReferenceKind::Tag("v1.0.0".to_string()));
1721    }
1722
1723    #[test]
1724    fn parse_reference_with_digest() {
1725        let r = OciReference::parse(
1726            "ghcr.io/myorg/mymodule@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
1727        )
1728        .unwrap();
1729        assert_eq!(r.registry, "ghcr.io");
1730        assert_eq!(r.repository, "myorg/mymodule");
1731        assert_eq!(
1732            r.reference,
1733            ReferenceKind::Digest(
1734                "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
1735                    .to_string()
1736            )
1737        );
1738    }
1739
1740    #[test]
1741    fn parse_reference_default_tag() {
1742        let r = OciReference::parse("ghcr.io/myorg/mymodule").unwrap();
1743        assert_eq!(r.registry, "ghcr.io");
1744        assert_eq!(r.repository, "myorg/mymodule");
1745        assert_eq!(r.reference, ReferenceKind::Tag("latest".to_string()));
1746    }
1747
1748    #[test]
1749    fn parse_reference_default_registry() {
1750        let r = OciReference::parse("myorg/mymodule:v2").unwrap();
1751        assert_eq!(r.registry, "docker.io");
1752        assert_eq!(r.repository, "myorg/mymodule");
1753        assert_eq!(r.reference, ReferenceKind::Tag("v2".to_string()));
1754    }
1755
1756    #[test]
1757    fn parse_reference_docker_library() {
1758        let r = OciReference::parse("ubuntu").unwrap();
1759        assert_eq!(r.registry, "docker.io");
1760        assert_eq!(r.repository, "library/ubuntu");
1761        assert_eq!(r.reference, ReferenceKind::Tag("latest".to_string()));
1762    }
1763
1764    #[test]
1765    fn parse_reference_localhost() {
1766        let r = OciReference::parse("localhost:5000/mymodule:dev").unwrap();
1767        assert_eq!(r.registry, "localhost:5000");
1768        assert_eq!(r.repository, "mymodule");
1769        assert_eq!(r.reference, ReferenceKind::Tag("dev".to_string()));
1770    }
1771
1772    #[test]
1773    fn parse_reference_nested_repo() {
1774        let r = OciReference::parse("registry.example.com/a/b/c:latest").unwrap();
1775        assert_eq!(r.registry, "registry.example.com");
1776        assert_eq!(r.repository, "a/b/c");
1777        assert_eq!(r.reference, ReferenceKind::Tag("latest".to_string()));
1778    }
1779
1780    #[test]
1781    fn parse_empty_reference_fails() {
1782        assert!(OciReference::parse("").is_err());
1783    }
1784
1785    #[test]
1786    fn reference_display() {
1787        let r = OciReference {
1788            registry: "ghcr.io".to_string(),
1789            repository: "myorg/mymod".to_string(),
1790            reference: ReferenceKind::Tag("v1".to_string()),
1791        };
1792        assert_eq!(r.to_string(), "ghcr.io/myorg/mymod:v1");
1793
1794        let r2 = OciReference {
1795            registry: "ghcr.io".to_string(),
1796            repository: "myorg/mymod".to_string(),
1797            reference: ReferenceKind::Digest("sha256:abc".to_string()),
1798        };
1799        assert_eq!(r2.to_string(), "ghcr.io/myorg/mymod@sha256:abc");
1800    }
1801
1802    #[test]
1803    fn api_base_https() {
1804        let r = OciReference::parse("ghcr.io/test/repo:v1").unwrap();
1805        assert_eq!(r.api_base(), "https://ghcr.io/v2");
1806    }
1807
1808    #[test]
1809    fn api_base_localhost_http() {
1810        let r = OciReference::parse("localhost:5000/test:v1").unwrap();
1811        assert!(r.api_base().starts_with("http://"));
1812    }
1813
1814    // --- Config blob round-trip ---
1815
1816    #[test]
1817    fn config_blob_round_trip() {
1818        let module_yaml = "apiVersion: cfgd.io/v1alpha1\nkind: Module\nmetadata:\n  name: test\nspec:\n  packages:\n    - name: curl\n";
1819        let config_blob = serde_json::to_vec(&serde_json::json!({
1820            "moduleYaml": module_yaml,
1821        }))
1822        .unwrap();
1823
1824        let parsed: serde_json::Value = serde_json::from_slice(&config_blob).unwrap();
1825        assert_eq!(parsed["moduleYaml"].as_str().unwrap(), module_yaml);
1826    }
1827
1828    // --- tar+gzip round-trip ---
1829
1830    #[test]
1831    fn tar_gz_round_trip() {
1832        let dir = tempfile::tempdir().unwrap();
1833        let dir_path = dir.path();
1834
1835        // Create test files
1836        std::fs::write(dir_path.join("module.yaml"), "name: test\n").unwrap();
1837        std::fs::create_dir(dir_path.join("files")).unwrap();
1838        std::fs::write(
1839            dir_path.join("files/config.toml"),
1840            "[section]\nkey = \"value\"\n",
1841        )
1842        .unwrap();
1843
1844        // Create archive
1845        let archive = create_tar_gz(dir_path).unwrap();
1846        assert!(!archive.is_empty());
1847
1848        // Extract to new directory
1849        let out_dir = tempfile::tempdir().unwrap();
1850        extract_tar_gz(&archive, out_dir.path()).unwrap();
1851
1852        // Verify contents
1853        let module_yaml = std::fs::read_to_string(out_dir.path().join("module.yaml")).unwrap();
1854        assert_eq!(module_yaml, "name: test\n");
1855
1856        let config = std::fs::read_to_string(out_dir.path().join("files/config.toml")).unwrap();
1857        assert_eq!(config, "[section]\nkey = \"value\"\n");
1858    }
1859
1860    // --- Docker config.json parsing ---
1861
1862    #[test]
1863    fn parse_docker_config_auth() {
1864        let config_json = r#"{
1865            "auths": {
1866                "ghcr.io": {
1867                    "auth": "dXNlcjpwYXNz"
1868                }
1869            }
1870        }"#;
1871        let config: DockerConfig = serde_json::from_str(config_json).unwrap();
1872        let auth = resolve_from_docker_auths(&config.auths, "ghcr.io").unwrap();
1873        assert_eq!(auth.username, "user");
1874        assert_eq!(auth.password, "pass");
1875    }
1876
1877    #[test]
1878    fn parse_docker_config_with_url_key() {
1879        let config_json = r#"{
1880            "auths": {
1881                "https://index.docker.io/v1/": {
1882                    "auth": "ZG9ja2VyOnNlY3JldA=="
1883                }
1884            }
1885        }"#;
1886        let config: DockerConfig = serde_json::from_str(config_json).unwrap();
1887        let auth = resolve_from_docker_auths(&config.auths, "docker.io").unwrap();
1888        assert_eq!(auth.username, "docker");
1889        assert_eq!(auth.password, "secret");
1890    }
1891
1892    #[test]
1893    fn parse_docker_config_no_auth() {
1894        let config_json = r#"{ "auths": {} }"#;
1895        let config: DockerConfig = serde_json::from_str(config_json).unwrap();
1896        let auth = resolve_from_docker_auths(&config.auths, "ghcr.io");
1897        assert!(auth.is_none());
1898    }
1899
1900    #[test]
1901    fn parse_docker_config_cred_helpers() {
1902        let config_json = r#"{
1903            "auths": {},
1904            "credHelpers": {
1905                "gcr.io": "gcloud",
1906                "ghcr.io": "gh"
1907            }
1908        }"#;
1909        let config: DockerConfig = serde_json::from_str(config_json).unwrap();
1910        assert_eq!(config.cred_helpers.get("gcr.io").unwrap(), "gcloud");
1911        assert_eq!(config.cred_helpers.get("ghcr.io").unwrap(), "gh");
1912    }
1913
1914    // --- Base64 ---
1915
1916    #[test]
1917    fn base64_encode_decode() {
1918        let original = "user:password";
1919        let encoded = base64_encode(original.as_bytes());
1920        let decoded = base64_decode(&encoded).unwrap();
1921        assert_eq!(String::from_utf8(decoded).unwrap(), original);
1922    }
1923
1924    #[test]
1925    fn base64_decode_known() {
1926        // "user:pass" → "dXNlcjpwYXNz"
1927        let decoded = base64_decode("dXNlcjpwYXNz").unwrap();
1928        assert_eq!(String::from_utf8(decoded).unwrap(), "user:pass");
1929    }
1930
1931    #[test]
1932    fn base64_empty() {
1933        assert_eq!(base64_encode(b""), "");
1934        assert_eq!(base64_decode("").unwrap(), Vec::<u8>::new());
1935    }
1936
1937    // --- Registry auth resolution ---
1938
1939    #[test]
1940    fn registry_auth_from_env() {
1941        // Save and restore env
1942        let old_user = std::env::var("REGISTRY_USERNAME").ok();
1943        let old_pass = std::env::var("REGISTRY_PASSWORD").ok();
1944
1945        // SAFETY: test runs single-threaded; no other thread reads these vars.
1946        unsafe {
1947            std::env::set_var("REGISTRY_USERNAME", "testuser");
1948            std::env::set_var("REGISTRY_PASSWORD", "testpass");
1949        }
1950
1951        let auth = RegistryAuth::resolve("any-registry.io").unwrap();
1952        assert_eq!(auth.username, "testuser");
1953        assert_eq!(auth.password, "testpass");
1954
1955        // Restore
1956        unsafe {
1957            match old_user {
1958                Some(v) => std::env::set_var("REGISTRY_USERNAME", v),
1959                None => std::env::remove_var("REGISTRY_USERNAME"),
1960            }
1961            match old_pass {
1962                Some(v) => std::env::set_var("REGISTRY_PASSWORD", v),
1963                None => std::env::remove_var("REGISTRY_PASSWORD"),
1964            }
1965        }
1966    }
1967
1968    // --- SHA256 ---
1969
1970    #[test]
1971    fn sha256_digest_known() {
1972        let digest = sha256_digest(b"hello");
1973        assert!(digest.starts_with("sha256:"));
1974        assert_eq!(digest.len(), 7 + 64); // "sha256:" + 64 hex chars
1975    }
1976
1977    // --- OCI manifest serialization ---
1978
1979    #[test]
1980    fn oci_manifest_serialization() {
1981        let manifest = OciManifest {
1982            schema_version: 2,
1983            media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
1984            config: OciDescriptor {
1985                media_type: MEDIA_TYPE_MODULE_CONFIG.to_string(),
1986                digest: "sha256:abc123".to_string(),
1987                size: 100,
1988                annotations: HashMap::new(),
1989            },
1990            layers: vec![OciDescriptor {
1991                media_type: MEDIA_TYPE_MODULE_LAYER.to_string(),
1992                digest: "sha256:def456".to_string(),
1993                size: 2048,
1994                annotations: HashMap::new(),
1995            }],
1996            annotations: HashMap::new(),
1997        };
1998
1999        let json = serde_json::to_string(&manifest).unwrap();
2000        assert!(json.contains("schemaVersion"));
2001        assert!(json.contains(MEDIA_TYPE_MODULE_CONFIG));
2002        assert!(json.contains(MEDIA_TYPE_MODULE_LAYER));
2003
2004        // Round-trip
2005        let parsed: OciManifest = serde_json::from_str(&json).unwrap();
2006        assert_eq!(parsed.schema_version, 2);
2007        assert_eq!(parsed.config.digest, "sha256:abc123");
2008        assert_eq!(parsed.layers.len(), 1);
2009        assert_eq!(parsed.layers[0].digest, "sha256:def456");
2010    }
2011
2012    // --- Www-Authenticate parsing ---
2013
2014    #[test]
2015    fn extract_auth_params() {
2016        let header = r#"Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:myorg/mymod:pull""#;
2017        assert_eq!(
2018            extract_auth_param(header, "realm"),
2019            Some("https://ghcr.io/token")
2020        );
2021        assert_eq!(extract_auth_param(header, "service"), Some("ghcr.io"));
2022        assert_eq!(
2023            extract_auth_param(header, "scope"),
2024            Some("repository:myorg/mymod:pull")
2025        );
2026        assert_eq!(extract_auth_param(header, "nonexistent"), None);
2027    }
2028
2029    // --- Build ---
2030
2031    #[test]
2032    fn build_module_rejects_missing_module_yaml() {
2033        let dir = tempfile::tempdir().unwrap();
2034        let result = build_module(dir.path(), None, None);
2035        assert!(matches!(result, Err(OciError::ModuleYamlNotFound { .. })));
2036    }
2037
2038    #[test]
2039    fn detect_container_runtime_returns_option() {
2040        let rt = detect_container_runtime();
2041        if let Some(name) = rt {
2042            assert!(name == "docker" || name == "podman");
2043        }
2044    }
2045
2046    #[test]
2047    fn generate_build_dockerfile_content() {
2048        let dockerfile = build_dockerfile("ubuntu:22.04", &["curl", "wget"]);
2049        assert!(dockerfile.contains("FROM ubuntu:22.04"));
2050        assert!(dockerfile.contains("curl"));
2051        assert!(dockerfile.contains("wget"));
2052        assert!(dockerfile.contains("WORKDIR /build"));
2053    }
2054
2055    #[test]
2056    fn generate_build_dockerfile_no_packages() {
2057        let dockerfile = build_dockerfile("alpine:3.18", &[]);
2058        assert!(dockerfile.contains("FROM alpine:3.18"));
2059        assert!(!dockerfile.contains("apt-get"));
2060    }
2061
2062    // --- Multi-platform ---
2063
2064    #[test]
2065    fn oci_index_manifest_serialization() {
2066        let index = OciIndex {
2067            schema_version: 2,
2068            media_type: MEDIA_TYPE_OCI_INDEX.to_string(),
2069            manifests: vec![
2070                OciPlatformManifest {
2071                    media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
2072                    digest: "sha256:abc".to_string(),
2073                    size: 1024,
2074                    platform: OciPlatform {
2075                        os: "linux".to_string(),
2076                        architecture: "amd64".to_string(),
2077                    },
2078                },
2079                OciPlatformManifest {
2080                    media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
2081                    digest: "sha256:def".to_string(),
2082                    size: 2048,
2083                    platform: OciPlatform {
2084                        os: "linux".to_string(),
2085                        architecture: "arm64".to_string(),
2086                    },
2087                },
2088            ],
2089        };
2090        let json = serde_json::to_string(&index).unwrap();
2091        assert!(json.contains("schemaVersion"));
2092        assert!(json.contains("amd64"));
2093        assert!(json.contains("arm64"));
2094
2095        let parsed: OciIndex = serde_json::from_str(&json).unwrap();
2096        assert_eq!(parsed.manifests.len(), 2);
2097    }
2098
2099    #[test]
2100    fn parse_platform_target_valid() {
2101        let (os, arch) = parse_platform_target("linux/amd64").unwrap();
2102        assert_eq!(os, "linux");
2103        assert_eq!(arch, "amd64");
2104    }
2105
2106    #[test]
2107    fn parse_platform_target_invalid() {
2108        assert!(parse_platform_target("invalid").is_err());
2109    }
2110
2111    // --- Dockerfile generation ---
2112
2113    #[test]
2114    fn build_dockerfile_debian_default() {
2115        let df = build_dockerfile("ubuntu:22.04", &["curl", "jq"]);
2116        assert!(df.contains("FROM ubuntu:22.04"));
2117        assert!(df.contains("apt-get"));
2118        assert!(df.contains("curl jq"));
2119        assert!(df.contains("rm -rf /var/lib/apt/lists"));
2120    }
2121
2122    #[test]
2123    fn build_dockerfile_alpine() {
2124        let df = build_dockerfile("alpine:3.18", &["curl"]);
2125        assert!(df.contains("apk add --no-cache"));
2126        assert!(!df.contains("apt-get"));
2127    }
2128
2129    #[test]
2130    fn build_dockerfile_fedora() {
2131        let df = build_dockerfile("fedora:39", &["strace"]);
2132        assert!(df.contains("dnf install -y"));
2133    }
2134
2135    #[test]
2136    fn build_dockerfile_no_packages() {
2137        let df = build_dockerfile("ubuntu:22.04", &[]);
2138        assert!(!df.contains("RUN"));
2139        assert!(df.contains("WORKDIR /build"));
2140    }
2141
2142    // --- Signing ---
2143
2144    #[test]
2145    fn sign_artifact_rejects_when_cosign_missing() {
2146        if crate::command_available("cosign") {
2147            return;
2148        }
2149        let result = sign_artifact("ghcr.io/test/mod:v1", None);
2150        assert!(matches!(result, Err(OciError::ToolNotFound { .. })));
2151    }
2152
2153    #[test]
2154    fn verify_signature_rejects_keyless_without_identity() {
2155        let result = verify_signature(
2156            "ghcr.io/test/mod:v1",
2157            &VerifyOptions {
2158                key: None,
2159                identity: None,
2160                issuer: None,
2161            },
2162        );
2163        assert!(matches!(result, Err(OciError::VerificationFailed { .. })));
2164    }
2165
2166    #[test]
2167    fn verify_signature_rejects_when_cosign_missing() {
2168        if crate::command_available("cosign") {
2169            return;
2170        }
2171        let result = verify_signature(
2172            "ghcr.io/test/mod:v1",
2173            &VerifyOptions {
2174                key: Some("cosign.pub"),
2175                identity: None,
2176                issuer: None,
2177            },
2178        );
2179        assert!(matches!(result, Err(OciError::ToolNotFound { .. })));
2180    }
2181
2182    // --- Attestations ---
2183
2184    #[test]
2185    fn attach_attestation_rejects_when_cosign_missing() {
2186        if crate::command_available("cosign") {
2187            return;
2188        }
2189        let result = attach_attestation("ghcr.io/test/mod:v1", "provenance.json", None);
2190        assert!(matches!(result, Err(OciError::ToolNotFound { .. })));
2191    }
2192
2193    #[test]
2194    fn verify_attestation_rejects_keyless_without_identity() {
2195        let result = verify_attestation(
2196            "ghcr.io/test/mod:v1",
2197            "slsaprovenance",
2198            &VerifyOptions {
2199                key: None,
2200                identity: None,
2201                issuer: None,
2202            },
2203        );
2204        assert!(matches!(result, Err(OciError::VerificationFailed { .. })));
2205    }
2206
2207    #[test]
2208    fn verify_attestation_rejects_when_cosign_missing() {
2209        if crate::command_available("cosign") {
2210            return;
2211        }
2212        let result = verify_attestation(
2213            "ghcr.io/test/mod:v1",
2214            "slsaprovenance",
2215            &VerifyOptions {
2216                key: Some("cosign.pub"),
2217                identity: None,
2218                issuer: None,
2219            },
2220        );
2221        assert!(matches!(result, Err(OciError::ToolNotFound { .. })));
2222    }
2223
2224    #[test]
2225    fn generate_slsa_provenance_creates_valid_json() {
2226        let prov = generate_slsa_provenance(
2227            "ghcr.io/test/mod:v1",
2228            "sha256:abc123",
2229            "https://github.com/myorg/myrepo",
2230            "abc123def",
2231        )
2232        .unwrap();
2233        let parsed: serde_json::Value = serde_json::from_str(&prov).unwrap();
2234        assert_eq!(parsed["predicateType"], "https://slsa.dev/provenance/v1");
2235        assert_eq!(parsed["subject"][0]["name"], "ghcr.io/test/mod:v1");
2236        assert_eq!(parsed["subject"][0]["digest"]["sha256"], "abc123");
2237    }
2238
2239    #[test]
2240    fn insecure_registry_env_var() {
2241        // Save and restore env var to avoid affecting other tests
2242        let prev = std::env::var("OCI_INSECURE_REGISTRIES").ok();
2243
2244        // SAFETY: test is single-threaded for this env var; save/restore brackets usage
2245        unsafe {
2246            std::env::set_var(
2247                "OCI_INSECURE_REGISTRIES",
2248                "myregistry:5000,other.local:8080",
2249            );
2250        }
2251        assert!(is_insecure_registry("myregistry:5000"));
2252        assert!(is_insecure_registry("other.local:8080"));
2253        assert!(!is_insecure_registry("ghcr.io"));
2254        assert!(!is_insecure_registry("myregistry:5001"));
2255
2256        // Verify api_base uses HTTP for insecure registries
2257        let r = OciReference::parse("myregistry:5000/test/mod:v1").unwrap();
2258        assert!(r.api_base().starts_with("http://"));
2259
2260        let r2 = OciReference::parse("ghcr.io/test/mod:v1").unwrap();
2261        assert!(r2.api_base().starts_with("https://"));
2262
2263        // Restore
2264        unsafe {
2265            match prev {
2266                Some(v) => std::env::set_var("OCI_INSECURE_REGISTRIES", v),
2267                None => std::env::remove_var("OCI_INSECURE_REGISTRIES"),
2268            }
2269        }
2270    }
2271
2272    // --- decode_docker_auth ---
2273
2274    #[test]
2275    fn decode_docker_auth_valid() {
2276        // "user:pass" base64 = "dXNlcjpwYXNz"
2277        let result = decode_docker_auth("dXNlcjpwYXNz");
2278        assert!(result.is_some());
2279        let auth = result.unwrap();
2280        assert_eq!(auth.username, "user");
2281        assert_eq!(auth.password, "pass");
2282    }
2283
2284    #[test]
2285    fn decode_docker_auth_no_colon() {
2286        let result = decode_docker_auth("bm9jb2xvbg=="); // "nocolon"
2287        assert!(result.is_none());
2288    }
2289
2290    #[test]
2291    fn decode_docker_auth_empty_password() {
2292        // "user:" base64 = "dXNlcjo="
2293        let result = decode_docker_auth("dXNlcjo=");
2294        assert!(result.is_some());
2295        let auth = result.unwrap();
2296        assert_eq!(auth.username, "user");
2297        assert_eq!(auth.password, "");
2298    }
2299
2300    #[test]
2301    fn decode_docker_auth_invalid_base64() {
2302        let result = decode_docker_auth("!!!invalid!!!");
2303        assert!(result.is_none());
2304    }
2305
2306    #[test]
2307    fn decode_docker_auth_password_with_colons() {
2308        // "user:pa:ss:word" base64 = "dXNlcjpwYTpzczp3b3Jk"
2309        let result = decode_docker_auth("dXNlcjpwYTpzczp3b3Jk");
2310        assert!(result.is_some());
2311        let auth = result.unwrap();
2312        assert_eq!(auth.username, "user");
2313        assert_eq!(auth.password, "pa:ss:word");
2314    }
2315
2316    #[test]
2317    fn decode_docker_auth_empty_username_rejected() {
2318        // ":password" base64 = "OnBhc3N3b3Jk"
2319        let result = decode_docker_auth("OnBhc3N3b3Jk");
2320        assert!(result.is_none());
2321    }
2322
2323    // --- rust_arch_to_oci ---
2324
2325    #[test]
2326    fn rust_arch_to_oci_known() {
2327        assert_eq!(rust_arch_to_oci("x86_64"), "amd64");
2328        assert_eq!(rust_arch_to_oci("aarch64"), "arm64");
2329        assert_eq!(rust_arch_to_oci("arm"), "arm");
2330        assert_eq!(rust_arch_to_oci("s390x"), "s390x");
2331        assert_eq!(rust_arch_to_oci("powerpc64"), "ppc64le");
2332    }
2333
2334    #[test]
2335    fn rust_arch_to_oci_unknown_passes_through() {
2336        assert_eq!(rust_arch_to_oci("mips64"), "mips64");
2337        assert_eq!(rust_arch_to_oci("riscv64"), "riscv64");
2338    }
2339
2340    // --- detect_pkg_install_cmd ---
2341
2342    #[test]
2343    fn detect_pkg_install_cmd_centos() {
2344        assert_eq!(detect_pkg_install_cmd("centos:8"), "yum install -y");
2345    }
2346
2347    #[test]
2348    fn detect_pkg_install_cmd_archlinux() {
2349        assert_eq!(
2350            detect_pkg_install_cmd("archlinux:latest"),
2351            "pacman -Sy --noconfirm"
2352        );
2353    }
2354
2355    #[test]
2356    fn detect_pkg_install_cmd_unknown_defaults_to_apt() {
2357        let cmd = detect_pkg_install_cmd("someunknownimage:latest");
2358        assert!(
2359            cmd.contains("apt-get"),
2360            "unknown image should default to apt-get, got: {cmd}"
2361        );
2362    }
2363
2364    #[test]
2365    fn detect_pkg_install_cmd_rockylinux() {
2366        assert_eq!(detect_pkg_install_cmd("rockylinux:9"), "dnf install -y");
2367    }
2368
2369    #[test]
2370    fn detect_pkg_install_cmd_almalinux() {
2371        assert_eq!(detect_pkg_install_cmd("almalinux:8"), "dnf install -y");
2372    }
2373
2374    #[test]
2375    fn detect_pkg_install_cmd_fedora() {
2376        assert_eq!(detect_pkg_install_cmd("fedora:40"), "dnf install -y");
2377    }
2378
2379    #[test]
2380    fn detect_pkg_install_cmd_alpine_with_registry() {
2381        assert_eq!(
2382            detect_pkg_install_cmd("docker.io/library/alpine:3.19"),
2383            "apk add --no-cache"
2384        );
2385    }
2386
2387    // --- resolve_from_docker_auths ---
2388
2389    #[test]
2390    fn resolve_from_docker_auths_tries_https_prefix() {
2391        let mut auths = HashMap::new();
2392        auths.insert(
2393            "https://registry.example.com".to_string(),
2394            DockerAuthEntry {
2395                auth: Some("dXNlcjpwYXNz".to_string()), // user:pass
2396            },
2397        );
2398        let result = resolve_from_docker_auths(&auths, "registry.example.com");
2399        assert!(result.is_some());
2400        let auth = result.unwrap();
2401        assert_eq!(auth.username, "user");
2402        assert_eq!(auth.password, "pass");
2403    }
2404
2405    #[test]
2406    fn resolve_from_docker_auths_tries_v2_suffix() {
2407        let mut auths = HashMap::new();
2408        auths.insert(
2409            "https://myregistry.io/v2/".to_string(),
2410            DockerAuthEntry {
2411                auth: Some("dXNlcjpwYXNz".to_string()),
2412            },
2413        );
2414        let result = resolve_from_docker_auths(&auths, "myregistry.io");
2415        assert!(result.is_some());
2416        assert_eq!(result.unwrap().username, "user");
2417    }
2418
2419    #[test]
2420    fn resolve_from_docker_auths_no_match() {
2421        let mut auths = HashMap::new();
2422        auths.insert(
2423            "https://other.io".to_string(),
2424            DockerAuthEntry {
2425                auth: Some("dXNlcjpwYXNz".to_string()),
2426            },
2427        );
2428        let result = resolve_from_docker_auths(&auths, "registry.example.com");
2429        assert!(result.is_none());
2430    }
2431
2432    // --- current_platform ---
2433
2434    #[test]
2435    fn current_platform_returns_valid_format() {
2436        let platform = current_platform();
2437        assert!(
2438            platform.contains('/'),
2439            "platform should be os/arch format: {platform}"
2440        );
2441        let parts: Vec<&str> = platform.split('/').collect();
2442        assert_eq!(parts.len(), 2);
2443        assert!(!parts[0].is_empty());
2444        assert!(!parts[1].is_empty());
2445    }
2446
2447    // --- parse_platform_target edge cases ---
2448
2449    #[test]
2450    fn parse_platform_target_three_parts_gives_arch_with_slash() {
2451        // split_once('/') on "linux/amd64/extra" gives ("linux", "amd64/extra")
2452        let result = parse_platform_target("linux/amd64/extra");
2453        assert!(result.is_ok());
2454        let (os, arch) = result.unwrap();
2455        assert_eq!(os, "linux");
2456        assert_eq!(arch, "amd64/extra");
2457    }
2458
2459    #[test]
2460    fn parse_platform_target_no_slash_fails() {
2461        let result = parse_platform_target("linuxamd64");
2462        assert!(
2463            matches!(result, Err(OciError::BuildError { .. })),
2464            "expected BuildError, got: {result:?}"
2465        );
2466        let err_msg = format!("{}", result.unwrap_err());
2467        assert!(
2468            err_msg.contains("invalid platform target"),
2469            "expected 'invalid platform target' message, got: {err_msg}"
2470        );
2471    }
2472
2473    // --- sha256_digest ---
2474
2475    #[test]
2476    fn sha256_digest_known_empty() {
2477        let digest = sha256_digest(b"");
2478        assert!(digest.starts_with("sha256:"));
2479        assert_eq!(digest.len(), 7 + 64); // "sha256:" + 64 hex chars
2480        assert_eq!(
2481            digest,
2482            "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
2483        );
2484    }
2485
2486    #[test]
2487    fn sha256_digest_known_hello() {
2488        let digest = sha256_digest(b"hello");
2489        // SHA256("hello") = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
2490        assert_eq!(
2491            digest,
2492            "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
2493        );
2494    }
2495
2496    // --- VerifyOptions ---
2497
2498    #[test]
2499    fn verify_options_default_keyless() {
2500        let opts = VerifyOptions {
2501            key: None,
2502            identity: Some("user@example.com"),
2503            issuer: Some("https://accounts.google.com"),
2504        };
2505        assert!(opts.key.is_none());
2506        assert_eq!(opts.identity.unwrap(), "user@example.com");
2507        assert_eq!(opts.issuer.unwrap(), "https://accounts.google.com");
2508    }
2509
2510    // --- mockito-based HTTP integration tests ---
2511
2512    /// Helper: extract host:port from a mockito server URL for use as OCI registry.
2513    fn registry_from_url(url: &str) -> String {
2514        url.trim_start_matches("http://")
2515            .trim_start_matches("https://")
2516            .trim_end_matches('/')
2517            .to_string()
2518    }
2519
2520    /// Helper: create a module directory with a valid module.yaml for push tests.
2521    fn create_test_module_dir() -> tempfile::TempDir {
2522        let dir = tempfile::tempdir().unwrap();
2523        std::fs::write(
2524            dir.path().join("module.yaml"),
2525            "apiVersion: cfgd.io/v1alpha1\nkind: Module\nmetadata:\n  name: test-mod\nspec:\n  packages:\n    - name: curl\n",
2526        )
2527        .unwrap();
2528        std::fs::write(dir.path().join("README.md"), "# Test module\n").unwrap();
2529        dir
2530    }
2531
2532    #[test]
2533    fn upload_blob_posts_then_puts() {
2534        let mut server = mockito::Server::new();
2535        let registry = registry_from_url(&server.url());
2536
2537        let oci_ref = OciReference {
2538            registry: registry.clone(),
2539            repository: "test/mod".to_string(),
2540            reference: ReferenceKind::Tag("v1".to_string()),
2541        };
2542
2543        let data = b"hello blob data";
2544        let expected_digest = sha256_digest(data);
2545
2546        // HEAD check returns 404 (blob doesn't exist)
2547        let head_mock = server
2548            .mock(
2549                "HEAD",
2550                mockito::Matcher::Regex(r"/v2/test/mod/blobs/sha256:.*".to_string()),
2551            )
2552            .with_status(404)
2553            .create();
2554
2555        // POST to initiate upload -> 202 with Location
2556        let upload_location = format!("{}/v2/test/mod/blobs/uploads/some-uuid", server.url());
2557        let post_mock = server
2558            .mock("POST", "/v2/test/mod/blobs/uploads/")
2559            .with_status(202)
2560            .with_header("Location", &upload_location)
2561            .create();
2562
2563        // PUT to complete upload
2564        let put_mock = server
2565            .mock(
2566                "PUT",
2567                mockito::Matcher::Regex(
2568                    r"/v2/test/mod/blobs/uploads/some-uuid\?digest=sha256:.*".to_string(),
2569                ),
2570            )
2571            .with_status(201)
2572            .create();
2573
2574        let agent = ureq::AgentBuilder::new()
2575            .timeout(std::time::Duration::from_secs(10))
2576            .build();
2577
2578        let result = upload_blob(&agent, &oci_ref, None, data, "application/octet-stream");
2579        assert!(result.is_ok(), "upload_blob failed: {:?}", result.err());
2580        assert_eq!(result.unwrap(), expected_digest);
2581
2582        head_mock.assert();
2583        post_mock.assert();
2584        put_mock.assert();
2585    }
2586
2587    #[test]
2588    fn upload_blob_skips_when_already_exists() {
2589        let mut server = mockito::Server::new();
2590        let registry = registry_from_url(&server.url());
2591
2592        let oci_ref = OciReference {
2593            registry,
2594            repository: "test/mod".to_string(),
2595            reference: ReferenceKind::Tag("v1".to_string()),
2596        };
2597
2598        let data = b"existing blob";
2599        let expected_digest = sha256_digest(data);
2600
2601        // HEAD check returns 200 (blob exists)
2602        let head_mock = server
2603            .mock(
2604                "HEAD",
2605                mockito::Matcher::Regex(r"/v2/test/mod/blobs/sha256:.*".to_string()),
2606            )
2607            .with_status(200)
2608            .create();
2609
2610        let agent = ureq::AgentBuilder::new()
2611            .timeout(std::time::Duration::from_secs(10))
2612            .build();
2613
2614        let result = upload_blob(&agent, &oci_ref, None, data, "application/octet-stream");
2615        assert!(result.is_ok());
2616        assert_eq!(result.unwrap(), expected_digest);
2617
2618        head_mock.assert();
2619        // POST and PUT should NOT have been called
2620    }
2621
2622    #[test]
2623    fn upload_blob_handles_relative_location() {
2624        let mut server = mockito::Server::new();
2625        let registry = registry_from_url(&server.url());
2626
2627        let oci_ref = OciReference {
2628            registry,
2629            repository: "test/mod".to_string(),
2630            reference: ReferenceKind::Tag("v1".to_string()),
2631        };
2632
2633        let data = b"blob with relative location";
2634
2635        // HEAD returns 404
2636        server
2637            .mock(
2638                "HEAD",
2639                mockito::Matcher::Regex(r"/v2/test/mod/blobs/sha256:.*".to_string()),
2640            )
2641            .with_status(404)
2642            .create();
2643
2644        // POST returns relative Location
2645        server
2646            .mock("POST", "/v2/test/mod/blobs/uploads/")
2647            .with_status(202)
2648            .with_header("Location", "/v2/test/mod/blobs/uploads/rel-uuid")
2649            .create();
2650
2651        // PUT with relative path (should be made absolute)
2652        let put_mock = server
2653            .mock(
2654                "PUT",
2655                mockito::Matcher::Regex(
2656                    r"/v2/test/mod/blobs/uploads/rel-uuid\?digest=sha256:.*".to_string(),
2657                ),
2658            )
2659            .with_status(201)
2660            .create();
2661
2662        let agent = ureq::AgentBuilder::new()
2663            .timeout(std::time::Duration::from_secs(10))
2664            .build();
2665
2666        let result = upload_blob(&agent, &oci_ref, None, data, "application/octet-stream");
2667        assert!(
2668            result.is_ok(),
2669            "upload_blob with relative location failed: {:?}",
2670            result.err()
2671        );
2672        put_mock.assert();
2673    }
2674
2675    #[test]
2676    fn push_module_inner_uploads_blobs_and_manifest() {
2677        let mut server = mockito::Server::new();
2678        let registry = registry_from_url(&server.url());
2679
2680        let oci_ref = OciReference {
2681            registry,
2682            repository: "test/pushmod".to_string(),
2683            reference: ReferenceKind::Tag("v1".to_string()),
2684        };
2685
2686        let module_dir = create_test_module_dir();
2687
2688        // Mock blob HEAD (not found) for config + layer
2689        server
2690            .mock(
2691                "HEAD",
2692                mockito::Matcher::Regex(r"/v2/test/pushmod/blobs/sha256:.*".to_string()),
2693            )
2694            .with_status(404)
2695            .expect_at_least(2)
2696            .create();
2697
2698        // Mock blob upload POST (config + layer)
2699        let upload_location = format!("{}/v2/test/pushmod/blobs/uploads/upload-id", server.url());
2700        server
2701            .mock("POST", "/v2/test/pushmod/blobs/uploads/")
2702            .with_status(202)
2703            .with_header("Location", &upload_location)
2704            .expect_at_least(2)
2705            .create();
2706
2707        // Mock blob upload PUT (config + layer)
2708        server
2709            .mock(
2710                "PUT",
2711                mockito::Matcher::Regex(
2712                    r"/v2/test/pushmod/blobs/uploads/upload-id\?digest=sha256:.*".to_string(),
2713                ),
2714            )
2715            .with_status(201)
2716            .expect_at_least(2)
2717            .create();
2718
2719        // Mock manifest PUT
2720        let manifest_mock = server
2721            .mock("PUT", "/v2/test/pushmod/manifests/v1")
2722            .with_status(201)
2723            .create();
2724
2725        let agent = ureq::AgentBuilder::new()
2726            .timeout(std::time::Duration::from_secs(10))
2727            .build();
2728
2729        let result = push_module_inner(
2730            &agent,
2731            module_dir.path(),
2732            &oci_ref,
2733            None,
2734            Some("linux/amd64"),
2735        );
2736        assert!(
2737            result.is_ok(),
2738            "push_module_inner failed: {:?}",
2739            result.err()
2740        );
2741
2742        let (digest, size) = result.unwrap();
2743        assert!(digest.starts_with("sha256:"));
2744        assert!(size > 0);
2745        manifest_mock.assert();
2746    }
2747
2748    #[test]
2749    fn push_module_inner_rejects_missing_module_yaml() {
2750        let dir = tempfile::tempdir().unwrap();
2751        // No module.yaml
2752
2753        let oci_ref = OciReference {
2754            registry: "localhost:9999".to_string(),
2755            repository: "test/mod".to_string(),
2756            reference: ReferenceKind::Tag("v1".to_string()),
2757        };
2758
2759        let agent = ureq::AgentBuilder::new().build();
2760        let result = push_module_inner(&agent, dir.path(), &oci_ref, None, None);
2761        assert!(matches!(result, Err(OciError::ModuleYamlNotFound { .. })));
2762    }
2763
2764    #[test]
2765    fn pull_module_downloads_and_verifies_digest() {
2766        let mut server = mockito::Server::new();
2767        let registry = registry_from_url(&server.url());
2768
2769        // Create a layer tarball from a temp module dir
2770        let src_dir = create_test_module_dir();
2771        let layer_data = create_tar_gz(src_dir.path()).unwrap();
2772        let layer_digest = sha256_digest(&layer_data);
2773
2774        // Build a manifest referencing this layer
2775        let config_blob = serde_json::to_vec(&serde_json::json!({
2776            "moduleYaml": "name: test",
2777        }))
2778        .unwrap();
2779        let config_digest = sha256_digest(&config_blob);
2780
2781        let manifest = serde_json::json!({
2782            "schemaVersion": 2,
2783            "mediaType": MEDIA_TYPE_OCI_MANIFEST,
2784            "config": {
2785                "mediaType": MEDIA_TYPE_MODULE_CONFIG,
2786                "digest": config_digest,
2787                "size": config_blob.len(),
2788            },
2789            "layers": [{
2790                "mediaType": MEDIA_TYPE_MODULE_LAYER,
2791                "digest": layer_digest,
2792                "size": layer_data.len(),
2793            }],
2794        });
2795
2796        // Mock manifest GET
2797        server
2798            .mock("GET", "/v2/test/pullmod/manifests/v1")
2799            .with_status(200)
2800            .with_header("Content-Type", MEDIA_TYPE_OCI_MANIFEST)
2801            .with_body(serde_json::to_string(&manifest).unwrap())
2802            .create();
2803
2804        // Mock layer blob GET
2805        server
2806            .mock(
2807                "GET",
2808                mockito::Matcher::Regex(r"/v2/test/pullmod/blobs/sha256:.*".to_string()),
2809            )
2810            .with_status(200)
2811            .with_body(layer_data)
2812            .create();
2813
2814        let output_dir = tempfile::tempdir().unwrap();
2815        let artifact_ref = format!("{}/test/pullmod:v1", registry);
2816        let result = pull_module(&artifact_ref, output_dir.path(), false, None);
2817        assert!(result.is_ok(), "pull_module failed: {:?}", result.err());
2818
2819        // Verify extracted files
2820        assert!(output_dir.path().join("module.yaml").exists());
2821        assert!(output_dir.path().join("README.md").exists());
2822    }
2823
2824    #[test]
2825    fn pull_module_detects_digest_mismatch() {
2826        let mut server = mockito::Server::new();
2827        let registry = registry_from_url(&server.url());
2828
2829        let real_layer_data = b"real layer content";
2830        // Use a fake digest that does NOT match the real data
2831        let fake_digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
2832
2833        let manifest = serde_json::json!({
2834            "schemaVersion": 2,
2835            "mediaType": MEDIA_TYPE_OCI_MANIFEST,
2836            "config": {
2837                "mediaType": MEDIA_TYPE_MODULE_CONFIG,
2838                "digest": "sha256:cfgcfg",
2839                "size": 10,
2840            },
2841            "layers": [{
2842                "mediaType": MEDIA_TYPE_MODULE_LAYER,
2843                "digest": fake_digest,
2844                "size": real_layer_data.len(),
2845            }],
2846        });
2847
2848        server
2849            .mock("GET", "/v2/test/badmod/manifests/v1")
2850            .with_status(200)
2851            .with_body(serde_json::to_string(&manifest).unwrap())
2852            .create();
2853
2854        server
2855            .mock(
2856                "GET",
2857                mockito::Matcher::Regex(r"/v2/test/badmod/blobs/sha256:.*".to_string()),
2858            )
2859            .with_status(200)
2860            .with_body(real_layer_data.as_slice())
2861            .create();
2862
2863        let output_dir = tempfile::tempdir().unwrap();
2864        let artifact_ref = format!("{}/test/badmod:v1", registry);
2865        let result = pull_module(&artifact_ref, output_dir.path(), false, None);
2866        assert!(result.is_err());
2867        let err_msg = format!("{}", result.unwrap_err());
2868        assert!(
2869            err_msg.contains("digest mismatch"),
2870            "expected digest mismatch error, got: {err_msg}"
2871        );
2872    }
2873
2874    #[test]
2875    fn pull_module_checks_signature_when_required() {
2876        let mut server = mockito::Server::new();
2877        let registry = registry_from_url(&server.url());
2878
2879        // No signature tag exists
2880        server
2881            .mock("HEAD", "/v2/test/sigmod/manifests/v1.sig")
2882            .with_status(404)
2883            .create();
2884
2885        let output_dir = tempfile::tempdir().unwrap();
2886        let artifact_ref = format!("{}/test/sigmod:v1", registry);
2887        let result = pull_module(&artifact_ref, output_dir.path(), true, None);
2888        assert!(result.is_err());
2889        assert!(
2890            matches!(result, Err(OciError::SignatureRequired { .. })),
2891            "expected SignatureRequired, got: {:?}",
2892            result.err()
2893        );
2894    }
2895
2896    #[test]
2897    fn authenticated_request_handles_401_token_exchange() {
2898        let mut server = mockito::Server::new();
2899
2900        let token_url = format!("{}/token", server.url());
2901        let www_auth = format!(
2902            r#"Bearer realm="{token_url}",service="test.io",scope="repository:test/repo:pull""#,
2903        );
2904
2905        // First request returns 401 with Www-Authenticate
2906        server
2907            .mock("GET", "/v2/test/repo/manifests/v1")
2908            .with_status(401)
2909            .with_header("Www-Authenticate", &www_auth)
2910            .expect(1)
2911            .create();
2912
2913        // Token endpoint returns a bearer token
2914        server
2915            .mock(
2916                "GET",
2917                mockito::Matcher::Regex(r"/token\?service=.*&scope=.*".to_string()),
2918            )
2919            .with_status(200)
2920            .with_body(r#"{"token":"my-bearer-token"}"#)
2921            .create();
2922
2923        // Retry with bearer token succeeds
2924        server
2925            .mock("GET", "/v2/test/repo/manifests/v1")
2926            .match_header("Authorization", "Bearer my-bearer-token")
2927            .with_status(200)
2928            .with_body("manifest content")
2929            .create();
2930
2931        let agent = ureq::AgentBuilder::new()
2932            .timeout(std::time::Duration::from_secs(10))
2933            .build();
2934
2935        let url = format!("{}/v2/test/repo/manifests/v1", server.url());
2936        let result = authenticated_request(&agent, "GET", &url, None, None, None, None);
2937        assert!(
2938            result.is_ok(),
2939            "authenticated_request failed: {:?}",
2940            result.err()
2941        );
2942    }
2943
2944    #[test]
2945    fn authenticated_request_fails_on_401_without_bearer() {
2946        let mut server = mockito::Server::new();
2947
2948        // 401 without Bearer challenge
2949        server
2950            .mock("GET", "/v2/test/repo/manifests/v1")
2951            .with_status(401)
2952            .with_header("Www-Authenticate", "Basic realm=\"test\"")
2953            .create();
2954
2955        let agent = ureq::AgentBuilder::new()
2956            .timeout(std::time::Duration::from_secs(10))
2957            .build();
2958
2959        let url = format!("{}/v2/test/repo/manifests/v1", server.url());
2960        let result = authenticated_request(&agent, "GET", &url, None, None, None, None);
2961        assert!(result.is_err());
2962        let err_msg = format!("{}", result.unwrap_err());
2963        assert!(
2964            err_msg.contains("no Bearer challenge"),
2965            "expected no Bearer challenge error, got: {err_msg}"
2966        );
2967    }
2968
2969    #[test]
2970    fn authenticated_request_passes_basic_auth() {
2971        let mut server = mockito::Server::new();
2972
2973        let auth = RegistryAuth {
2974            username: "user".to_string(),
2975            password: "pass".to_string(),
2976        };
2977
2978        server
2979            .mock("GET", "/v2/test/repo/tags/list")
2980            .match_header(
2981                "Authorization",
2982                mockito::Matcher::Regex("Basic .*".to_string()),
2983            )
2984            .with_status(200)
2985            .with_body("{}")
2986            .create();
2987
2988        let agent = ureq::AgentBuilder::new()
2989            .timeout(std::time::Duration::from_secs(10))
2990            .build();
2991
2992        let url = format!("{}/v2/test/repo/tags/list", server.url());
2993        let result = authenticated_request(&agent, "GET", &url, Some(&auth), None, None, None);
2994        let resp = result.expect("authenticated request should succeed with basic auth");
2995        assert_eq!(resp.status(), 200);
2996    }
2997
2998    #[test]
2999    fn get_bearer_token_parses_www_authenticate() {
3000        let mut server = mockito::Server::new();
3001
3002        let www_auth = format!(
3003            r#"Bearer realm="{}/auth/token",service="registry.example.com",scope="repository:lib/test:pull,push""#,
3004            server.url()
3005        );
3006
3007        server
3008            .mock(
3009                "GET",
3010                mockito::Matcher::Regex(r"/auth/token\?service=.*&scope=.*".to_string()),
3011            )
3012            .with_status(200)
3013            .with_body(r#"{"token":"tok-abc-123"}"#)
3014            .create();
3015
3016        let agent = ureq::AgentBuilder::new()
3017            .timeout(std::time::Duration::from_secs(10))
3018            .build();
3019
3020        let result = get_bearer_token(&agent, &www_auth, None);
3021        assert!(
3022            result.is_ok(),
3023            "get_bearer_token failed: {:?}",
3024            result.err()
3025        );
3026        assert_eq!(result.unwrap(), "tok-abc-123");
3027    }
3028
3029    #[test]
3030    fn get_bearer_token_uses_access_token_field() {
3031        let mut server = mockito::Server::new();
3032
3033        let www_auth = format!(r#"Bearer realm="{}/token",service="svc""#, server.url());
3034
3035        server
3036            .mock(
3037                "GET",
3038                mockito::Matcher::Regex(r"/token\?service=.*".to_string()),
3039            )
3040            .with_status(200)
3041            .with_body(r#"{"access_token":"alt-token-456"}"#)
3042            .create();
3043
3044        let agent = ureq::AgentBuilder::new()
3045            .timeout(std::time::Duration::from_secs(10))
3046            .build();
3047
3048        let result = get_bearer_token(&agent, &www_auth, None);
3049        assert!(result.is_ok());
3050        assert_eq!(result.unwrap(), "alt-token-456");
3051    }
3052
3053    #[test]
3054    fn get_bearer_token_fails_without_realm() {
3055        let agent = ureq::AgentBuilder::new().build();
3056        let result = get_bearer_token(&agent, "Bearer service=\"svc\"", None);
3057        assert!(result.is_err());
3058        let err_msg = format!("{}", result.unwrap_err());
3059        assert!(
3060            err_msg.contains("missing realm"),
3061            "expected missing realm error, got: {err_msg}"
3062        );
3063    }
3064
3065    #[test]
3066    fn get_bearer_token_sends_basic_auth_when_provided() {
3067        let mut server = mockito::Server::new();
3068
3069        let www_auth = format!(r#"Bearer realm="{}/token",service="svc""#, server.url());
3070
3071        let auth = RegistryAuth {
3072            username: "user".to_string(),
3073            password: "pass".to_string(),
3074        };
3075
3076        server
3077            .mock(
3078                "GET",
3079                mockito::Matcher::Regex(r"/token\?service=.*".to_string()),
3080            )
3081            .match_header(
3082                "Authorization",
3083                mockito::Matcher::Regex("Basic .*".to_string()),
3084            )
3085            .with_status(200)
3086            .with_body(r#"{"token":"authed-token"}"#)
3087            .create();
3088
3089        let agent = ureq::AgentBuilder::new()
3090            .timeout(std::time::Duration::from_secs(10))
3091            .build();
3092
3093        let result = get_bearer_token(&agent, &www_auth, Some(&auth));
3094        assert!(result.is_ok());
3095        assert_eq!(result.unwrap(), "authed-token");
3096    }
3097
3098    #[test]
3099    fn check_signature_exists_ok_when_present() {
3100        let mut server = mockito::Server::new();
3101        let registry = registry_from_url(&server.url());
3102
3103        let oci_ref = OciReference {
3104            registry,
3105            repository: "test/sigexist".to_string(),
3106            reference: ReferenceKind::Tag("v1".to_string()),
3107        };
3108
3109        server
3110            .mock("HEAD", "/v2/test/sigexist/manifests/v1.sig")
3111            .with_status(200)
3112            .create();
3113
3114        let agent = ureq::AgentBuilder::new()
3115            .timeout(std::time::Duration::from_secs(10))
3116            .build();
3117
3118        let result = check_signature_exists(&agent, &oci_ref, None);
3119        assert_eq!(result.unwrap(), (), "signature check should return Ok(())");
3120    }
3121
3122    #[test]
3123    fn check_signature_exists_fails_when_missing() {
3124        let mut server = mockito::Server::new();
3125        let registry = registry_from_url(&server.url());
3126
3127        let oci_ref = OciReference {
3128            registry,
3129            repository: "test/nosig".to_string(),
3130            reference: ReferenceKind::Tag("v1".to_string()),
3131        };
3132
3133        server
3134            .mock("HEAD", "/v2/test/nosig/manifests/v1.sig")
3135            .with_status(404)
3136            .create();
3137
3138        let agent = ureq::AgentBuilder::new()
3139            .timeout(std::time::Duration::from_secs(10))
3140            .build();
3141
3142        let result = check_signature_exists(&agent, &oci_ref, None);
3143        assert!(matches!(result, Err(OciError::SignatureRequired { .. })));
3144    }
3145
3146    #[test]
3147    fn check_signature_exists_digest_reference() {
3148        let mut server = mockito::Server::new();
3149        let registry = registry_from_url(&server.url());
3150
3151        let oci_ref = OciReference {
3152            registry,
3153            repository: "test/digsig".to_string(),
3154            reference: ReferenceKind::Digest("sha256:abc123".to_string()),
3155        };
3156
3157        // For digest refs, sig tag is "sha256-abc123.sig"
3158        server
3159            .mock("HEAD", "/v2/test/digsig/manifests/sha256-abc123.sig")
3160            .with_status(200)
3161            .create();
3162
3163        let agent = ureq::AgentBuilder::new()
3164            .timeout(std::time::Duration::from_secs(10))
3165            .build();
3166
3167        let result = check_signature_exists(&agent, &oci_ref, None);
3168        assert_eq!(
3169            result.unwrap(),
3170            (),
3171            "digest-referenced signature check should return Ok(())"
3172        );
3173    }
3174
3175    #[test]
3176    fn authenticated_request_sends_body_with_put() {
3177        let mut server = mockito::Server::new();
3178
3179        let body_content = b"test manifest content";
3180        server
3181            .mock("PUT", "/v2/test/repo/manifests/v1")
3182            .match_header("Content-Type", "application/vnd.oci.image.manifest.v1+json")
3183            .match_body(mockito::Matcher::Any)
3184            .with_status(201)
3185            .create();
3186
3187        let agent = ureq::AgentBuilder::new()
3188            .timeout(std::time::Duration::from_secs(10))
3189            .build();
3190
3191        let url = format!("{}/v2/test/repo/manifests/v1", server.url());
3192        let result = authenticated_request(
3193            &agent,
3194            "PUT",
3195            &url,
3196            None,
3197            None,
3198            Some("application/vnd.oci.image.manifest.v1+json"),
3199            Some(body_content),
3200        );
3201        let resp = result.expect("PUT with body should succeed");
3202        assert_eq!(resp.status(), 201);
3203    }
3204
3205    #[test]
3206    fn authenticated_request_returns_error_on_500() {
3207        let mut server = mockito::Server::new();
3208
3209        server
3210            .mock("GET", "/v2/test/repo/tags/list")
3211            .with_status(500)
3212            .create();
3213
3214        let agent = ureq::AgentBuilder::new()
3215            .timeout(std::time::Duration::from_secs(10))
3216            .build();
3217
3218        let url = format!("{}/v2/test/repo/tags/list", server.url());
3219        let result = authenticated_request(&agent, "GET", &url, None, None, None, None);
3220        assert!(result.is_err());
3221        let err_msg = format!("{}", result.unwrap_err());
3222        assert!(
3223            err_msg.contains("HTTP 500"),
3224            "expected HTTP 500 error, got: {err_msg}"
3225        );
3226    }
3227
3228    // --- tar_gz symlink handling ---
3229
3230    #[test]
3231    fn tar_gz_round_trip_via_create_and_extract() {
3232        let dir = tempfile::tempdir().unwrap();
3233        let subdir = dir.path().join("module");
3234        std::fs::create_dir_all(&subdir).unwrap();
3235        std::fs::write(subdir.join("module.yaml"), "name: test").unwrap();
3236
3237        let archive = create_tar_gz(&subdir).unwrap();
3238        assert!(!archive.is_empty());
3239
3240        let output = tempfile::tempdir().unwrap();
3241        let result = extract_tar_gz(&archive, output.path());
3242        assert!(result.is_ok());
3243        assert!(output.path().join("module.yaml").exists());
3244        let content = std::fs::read_to_string(output.path().join("module.yaml")).unwrap();
3245        assert_eq!(content, "name: test");
3246    }
3247
3248    // --- extract_tar_gz path traversal protection ---
3249
3250    #[test]
3251    fn extract_tar_gz_prevents_path_traversal_via_dotdot() {
3252        // Build a tar.gz archive with an entry whose path contains ".."
3253        // We must write the raw tar bytes to bypass the tar crate's own
3254        // set_path safety checks (which also reject "..")
3255        let mut buf = Vec::new();
3256        {
3257            let encoder = flate2::write::GzEncoder::new(&mut buf, flate2::Compression::default());
3258            let mut archive = tar::Builder::new(encoder);
3259
3260            let data = b"malicious content";
3261            let mut header = tar::Header::new_gnu();
3262            // Set a benign path first, then overwrite the raw bytes
3263            header.set_path("placeholder.txt").unwrap();
3264            header.set_size(data.len() as u64);
3265            header.set_mode(0o644);
3266
3267            // Overwrite the path field in the raw header with "../escaped.txt"
3268            let path_bytes = b"../escaped.txt";
3269            header.as_mut_bytes()[..path_bytes.len()].copy_from_slice(path_bytes);
3270            header.as_mut_bytes()[path_bytes.len()] = 0; // null-terminate
3271            header.set_cksum();
3272
3273            archive.append(&header, &data[..]).unwrap();
3274
3275            let encoder = archive.into_inner().unwrap();
3276            encoder.finish().unwrap();
3277        }
3278
3279        let output = tempfile::tempdir().unwrap();
3280        // extract_tar_gz uses unpack_in which silently skips entries with ".."
3281        // (returns Ok(false) for unsafe paths). The key safety guarantee is
3282        // that the file is NOT created outside the output directory.
3283        let _ = extract_tar_gz(&buf, output.path());
3284
3285        // Verify the malicious file was NOT created outside the output directory
3286        let parent = output.path().parent().unwrap();
3287        assert!(
3288            !parent.join("escaped.txt").exists(),
3289            "path traversal file should not have been created outside output dir"
3290        );
3291
3292        // Also verify nothing was created inside the output directory with this name
3293        assert!(
3294            !output.path().join("escaped.txt").exists(),
3295            "traversal entry should not have been extracted"
3296        );
3297    }
3298
3299    #[test]
3300    fn extract_tar_gz_skips_symlinks() {
3301        // Build a tar.gz archive containing a symlink entry
3302        let mut buf = Vec::new();
3303        {
3304            let encoder = flate2::write::GzEncoder::new(&mut buf, flate2::Compression::default());
3305            let mut archive = tar::Builder::new(encoder);
3306
3307            // Add a regular file first
3308            let data = b"normal file content";
3309            let mut header = tar::Header::new_gnu();
3310            header.set_path("normal.txt").unwrap();
3311            header.set_size(data.len() as u64);
3312            header.set_mode(0o644);
3313            header.set_cksum();
3314            archive.append(&header, &data[..]).unwrap();
3315
3316            // Add a symlink pointing outside the output directory
3317            let mut sym_header = tar::Header::new_gnu();
3318            sym_header.set_path("evil_link").unwrap();
3319            sym_header.set_entry_type(tar::EntryType::Symlink);
3320            sym_header.set_link_name("/etc/passwd").unwrap();
3321            sym_header.set_size(0);
3322            sym_header.set_mode(0o777);
3323            sym_header.set_cksum();
3324            archive.append(&sym_header, &[][..]).unwrap();
3325
3326            let encoder = archive.into_inner().unwrap();
3327            encoder.finish().unwrap();
3328        }
3329
3330        let output = tempfile::tempdir().unwrap();
3331        let result = extract_tar_gz(&buf, output.path());
3332        assert!(
3333            result.is_ok(),
3334            "extraction should succeed, skipping symlinks"
3335        );
3336
3337        // The regular file should exist
3338        let normal_content = std::fs::read_to_string(output.path().join("normal.txt")).unwrap();
3339        assert_eq!(normal_content, "normal file content");
3340
3341        // The symlink should NOT have been created
3342        assert!(
3343            !output.path().join("evil_link").exists(),
3344            "symlink entry should have been skipped during extraction"
3345        );
3346    }
3347
3348    #[test]
3349    fn extract_tar_gz_skips_hardlinks() {
3350        // Build a tar.gz archive containing a hardlink entry
3351        let mut buf = Vec::new();
3352        {
3353            let encoder = flate2::write::GzEncoder::new(&mut buf, flate2::Compression::default());
3354            let mut archive = tar::Builder::new(encoder);
3355
3356            // Add a regular file
3357            let data = b"target content";
3358            let mut header = tar::Header::new_gnu();
3359            header.set_path("target.txt").unwrap();
3360            header.set_size(data.len() as u64);
3361            header.set_mode(0o644);
3362            header.set_cksum();
3363            archive.append(&header, &data[..]).unwrap();
3364
3365            // Add a hardlink
3366            let mut link_header = tar::Header::new_gnu();
3367            link_header.set_path("hardlink.txt").unwrap();
3368            link_header.set_entry_type(tar::EntryType::Link);
3369            link_header.set_link_name("target.txt").unwrap();
3370            link_header.set_size(0);
3371            link_header.set_mode(0o644);
3372            link_header.set_cksum();
3373            archive.append(&link_header, &[][..]).unwrap();
3374
3375            let encoder = archive.into_inner().unwrap();
3376            encoder.finish().unwrap();
3377        }
3378
3379        let output = tempfile::tempdir().unwrap();
3380        let result = extract_tar_gz(&buf, output.path());
3381        assert!(
3382            result.is_ok(),
3383            "extraction should succeed, skipping hardlinks"
3384        );
3385
3386        // Regular file should exist
3387        assert!(output.path().join("target.txt").exists());
3388        let content = std::fs::read_to_string(output.path().join("target.txt")).unwrap();
3389        assert_eq!(content, "target content");
3390
3391        // Hardlink should NOT have been created (it gets skipped)
3392        assert!(
3393            !output.path().join("hardlink.txt").exists(),
3394            "hardlink entry should have been skipped during extraction"
3395        );
3396    }
3397
3398    #[test]
3399    fn extract_tar_gz_extracts_multiple_files_preserving_directories() {
3400        // Build a tar.gz archive with nested structure
3401        let mut buf = Vec::new();
3402        {
3403            let encoder = flate2::write::GzEncoder::new(&mut buf, flate2::Compression::default());
3404            let mut archive = tar::Builder::new(encoder);
3405
3406            // Add a directory entry
3407            let mut dir_header = tar::Header::new_gnu();
3408            dir_header.set_path("subdir/").unwrap();
3409            dir_header.set_entry_type(tar::EntryType::Directory);
3410            dir_header.set_size(0);
3411            dir_header.set_mode(0o755);
3412            dir_header.set_cksum();
3413            archive.append(&dir_header, &[][..]).unwrap();
3414
3415            // Add files in subdirectory
3416            let data1 = b"file one";
3417            let mut h1 = tar::Header::new_gnu();
3418            h1.set_path("subdir/one.txt").unwrap();
3419            h1.set_size(data1.len() as u64);
3420            h1.set_mode(0o644);
3421            h1.set_cksum();
3422            archive.append(&h1, &data1[..]).unwrap();
3423
3424            let data2 = b"file two";
3425            let mut h2 = tar::Header::new_gnu();
3426            h2.set_path("subdir/two.txt").unwrap();
3427            h2.set_size(data2.len() as u64);
3428            h2.set_mode(0o644);
3429            h2.set_cksum();
3430            archive.append(&h2, &data2[..]).unwrap();
3431
3432            let encoder = archive.into_inner().unwrap();
3433            encoder.finish().unwrap();
3434        }
3435
3436        let output = tempfile::tempdir().unwrap();
3437        extract_tar_gz(&buf, output.path()).unwrap();
3438
3439        assert_eq!(
3440            std::fs::read_to_string(output.path().join("subdir/one.txt")).unwrap(),
3441            "file one"
3442        );
3443        assert_eq!(
3444            std::fs::read_to_string(output.path().join("subdir/two.txt")).unwrap(),
3445            "file two"
3446        );
3447    }
3448
3449    #[test]
3450    fn extract_tar_gz_empty_archive() {
3451        // Build a tar.gz with no entries
3452        let mut buf = Vec::new();
3453        {
3454            let encoder = flate2::write::GzEncoder::new(&mut buf, flate2::Compression::default());
3455            let archive = tar::Builder::new(encoder);
3456            let encoder = archive.into_inner().unwrap();
3457            encoder.finish().unwrap();
3458        }
3459
3460        let output = tempfile::tempdir().unwrap();
3461        let result = extract_tar_gz(&buf, output.path());
3462        assert!(result.is_ok(), "empty archive should extract successfully");
3463        // Output directory should exist but be empty
3464        assert!(output.path().exists());
3465        let entries: Vec<_> = std::fs::read_dir(output.path()).unwrap().collect();
3466        assert_eq!(entries.len(), 0, "output directory should be empty");
3467    }
3468
3469    // --- resolve_from_docker_auths edge cases ---
3470
3471    #[test]
3472    fn resolve_from_docker_auths_v1_suffix() {
3473        let mut auths = HashMap::new();
3474        auths.insert(
3475            "https://myregistry.io/v1/".to_string(),
3476            DockerAuthEntry {
3477                auth: Some("dXNlcjpwYXNz".to_string()), // user:pass
3478            },
3479        );
3480        let result = resolve_from_docker_auths(&auths, "myregistry.io");
3481        assert!(result.is_some(), "should match v1 URL suffix");
3482        let auth = result.unwrap();
3483        assert_eq!(auth.username, "user");
3484        assert_eq!(auth.password, "pass");
3485    }
3486
3487    #[test]
3488    fn resolve_from_docker_auths_exact_hostname_match() {
3489        let mut auths = HashMap::new();
3490        auths.insert(
3491            "ghcr.io".to_string(),
3492            DockerAuthEntry {
3493                auth: Some("dXNlcjpwYXNz".to_string()),
3494            },
3495        );
3496        // Should match directly without needing https:// prefix
3497        let result = resolve_from_docker_auths(&auths, "ghcr.io");
3498        assert!(result.is_some(), "should match exact hostname");
3499        assert_eq!(result.unwrap().username, "user");
3500    }
3501
3502    #[test]
3503    fn resolve_from_docker_auths_docker_io_fallback_to_index() {
3504        // When querying "docker.io", should also check "index.docker.io" variants
3505        let mut auths = HashMap::new();
3506        auths.insert(
3507            "index.docker.io".to_string(),
3508            DockerAuthEntry {
3509                auth: Some("ZG9ja2VyOnNlY3JldA==".to_string()), // docker:secret
3510            },
3511        );
3512        let result = resolve_from_docker_auths(&auths, "docker.io");
3513        assert!(
3514            result.is_some(),
3515            "docker.io should fall back to index.docker.io"
3516        );
3517        let auth = result.unwrap();
3518        assert_eq!(auth.username, "docker");
3519        assert_eq!(auth.password, "secret");
3520    }
3521
3522    #[test]
3523    fn resolve_from_docker_auths_index_docker_io_fallback() {
3524        // When querying "index.docker.io", should also check index.docker.io variants
3525        let mut auths = HashMap::new();
3526        auths.insert(
3527            "https://index.docker.io/v1/".to_string(),
3528            DockerAuthEntry {
3529                auth: Some("ZG9ja2VyOnNlY3JldA==".to_string()), // docker:secret
3530            },
3531        );
3532        let result = resolve_from_docker_auths(&auths, "index.docker.io");
3533        assert!(
3534            result.is_some(),
3535            "index.docker.io should match https://index.docker.io/v1/ entry"
3536        );
3537        assert_eq!(result.unwrap().username, "docker");
3538    }
3539
3540    #[test]
3541    fn resolve_from_docker_auths_entry_with_null_auth_field() {
3542        let mut auths = HashMap::new();
3543        auths.insert("ghcr.io".to_string(), DockerAuthEntry { auth: None });
3544        let result = resolve_from_docker_auths(&auths, "ghcr.io");
3545        assert!(
3546            result.is_none(),
3547            "entry with null auth field should not resolve"
3548        );
3549    }
3550
3551    #[test]
3552    fn resolve_from_docker_auths_entry_with_invalid_base64() {
3553        let mut auths = HashMap::new();
3554        auths.insert(
3555            "ghcr.io".to_string(),
3556            DockerAuthEntry {
3557                auth: Some("not-valid-base64!!!".to_string()),
3558            },
3559        );
3560        let result = resolve_from_docker_auths(&auths, "ghcr.io");
3561        assert!(
3562            result.is_none(),
3563            "entry with invalid base64 should not resolve"
3564        );
3565    }
3566
3567    #[test]
3568    fn resolve_from_docker_auths_prefers_exact_match_over_url() {
3569        let mut auths = HashMap::new();
3570        // Exact match
3571        auths.insert(
3572            "ghcr.io".to_string(),
3573            DockerAuthEntry {
3574                auth: Some("ZXhhY3Q6bWF0Y2g=".to_string()), // exact:match
3575            },
3576        );
3577        // URL variant
3578        auths.insert(
3579            "https://ghcr.io".to_string(),
3580            DockerAuthEntry {
3581                auth: Some("dXJsOm1hdGNo".to_string()), // url:match
3582            },
3583        );
3584        let result = resolve_from_docker_auths(&auths, "ghcr.io");
3585        assert!(result.is_some());
3586        // The function tries candidates in order: exact, https://, https:///v2/, https:///v1/
3587        // So exact match should win
3588        assert_eq!(result.unwrap().username, "exact");
3589    }
3590
3591    // --- validate_verify_options ---
3592
3593    #[test]
3594    fn validate_verify_options_accepts_key_only() {
3595        let opts = VerifyOptions {
3596            key: Some("cosign.pub"),
3597            identity: None,
3598            issuer: None,
3599        };
3600        let result = validate_verify_options(&opts);
3601        assert!(result.is_ok(), "key-only verification should be valid");
3602    }
3603
3604    #[test]
3605    fn validate_verify_options_accepts_identity_only() {
3606        let opts = VerifyOptions {
3607            key: None,
3608            identity: Some("user@example.com"),
3609            issuer: None,
3610        };
3611        let result = validate_verify_options(&opts);
3612        assert!(result.is_ok(), "identity-only verification should be valid");
3613    }
3614
3615    #[test]
3616    fn validate_verify_options_accepts_issuer_only() {
3617        let opts = VerifyOptions {
3618            key: None,
3619            identity: None,
3620            issuer: Some("https://accounts.google.com"),
3621        };
3622        let result = validate_verify_options(&opts);
3623        assert!(result.is_ok(), "issuer-only verification should be valid");
3624    }
3625
3626    #[test]
3627    fn validate_verify_options_accepts_all_fields() {
3628        let opts = VerifyOptions {
3629            key: Some("cosign.pub"),
3630            identity: Some("user@example.com"),
3631            issuer: Some("https://accounts.google.com"),
3632        };
3633        let result = validate_verify_options(&opts);
3634        assert!(result.is_ok(), "all-fields verification should be valid");
3635    }
3636
3637    #[test]
3638    fn validate_verify_options_rejects_all_none() {
3639        let opts = VerifyOptions {
3640            key: None,
3641            identity: None,
3642            issuer: None,
3643        };
3644        let result = validate_verify_options(&opts);
3645        assert!(result.is_err(), "all-none should be rejected");
3646        let err = result.unwrap_err();
3647        let err_msg = format!("{err}");
3648        assert!(
3649            err_msg.contains("keyless verification requires identity or issuer constraint"),
3650            "error message should explain the constraint, got: {err_msg}"
3651        );
3652    }
3653
3654    // --- apply_verify_args ---
3655
3656    #[test]
3657    fn apply_verify_args_with_key() {
3658        let mut cmd = std::process::Command::new("echo");
3659        let opts = VerifyOptions {
3660            key: Some("/path/to/cosign.pub"),
3661            identity: None,
3662            issuer: None,
3663        };
3664        apply_verify_args(&mut cmd, &opts);
3665        // Extract the args from the command
3666        let args: Vec<_> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
3667        assert_eq!(args, vec!["--key", "/path/to/cosign.pub"]);
3668    }
3669
3670    #[test]
3671    fn apply_verify_args_keyless_with_identity_and_issuer() {
3672        let mut cmd = std::process::Command::new("echo");
3673        let opts = VerifyOptions {
3674            key: None,
3675            identity: Some("user@example.com"),
3676            issuer: Some("https://accounts.google.com"),
3677        };
3678        apply_verify_args(&mut cmd, &opts);
3679        let args: Vec<_> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
3680        assert_eq!(
3681            args,
3682            vec![
3683                "--certificate-identity-regexp",
3684                "user@example.com",
3685                "--certificate-oidc-issuer-regexp",
3686                "https://accounts.google.com"
3687            ]
3688        );
3689    }
3690
3691    #[test]
3692    fn apply_verify_args_keyless_with_identity_only_defaults_issuer() {
3693        let mut cmd = std::process::Command::new("echo");
3694        let opts = VerifyOptions {
3695            key: None,
3696            identity: Some("ci@github.com"),
3697            issuer: None,
3698        };
3699        apply_verify_args(&mut cmd, &opts);
3700        let args: Vec<_> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
3701        // When no issuer is provided, it defaults to ".*"
3702        assert_eq!(
3703            args,
3704            vec![
3705                "--certificate-identity-regexp",
3706                "ci@github.com",
3707                "--certificate-oidc-issuer-regexp",
3708                ".*"
3709            ]
3710        );
3711    }
3712
3713    #[test]
3714    fn apply_verify_args_keyless_with_issuer_only_defaults_identity() {
3715        let mut cmd = std::process::Command::new("echo");
3716        let opts = VerifyOptions {
3717            key: None,
3718            identity: None,
3719            issuer: Some("https://token.actions.githubusercontent.com"),
3720        };
3721        apply_verify_args(&mut cmd, &opts);
3722        let args: Vec<_> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
3723        // When no identity is provided, it defaults to ".*"
3724        assert_eq!(
3725            args,
3726            vec![
3727                "--certificate-identity-regexp",
3728                ".*",
3729                "--certificate-oidc-issuer-regexp",
3730                "https://token.actions.githubusercontent.com"
3731            ]
3732        );
3733    }
3734
3735    #[test]
3736    fn apply_verify_args_key_takes_precedence_over_keyless() {
3737        let mut cmd = std::process::Command::new("echo");
3738        let opts = VerifyOptions {
3739            key: Some("my.pub"),
3740            identity: Some("user@example.com"),
3741            issuer: Some("https://issuer.example.com"),
3742        };
3743        apply_verify_args(&mut cmd, &opts);
3744        let args: Vec<_> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
3745        // When key is provided, only --key should be added (no certificate args)
3746        assert_eq!(args, vec!["--key", "my.pub"]);
3747    }
3748
3749    // --- build_dockerfile additional coverage ---
3750
3751    #[test]
3752    fn build_dockerfile_centos() {
3753        let df = build_dockerfile("centos:8", &["vim", "git"]);
3754        assert!(df.contains("FROM centos:8"));
3755        assert!(df.contains("yum install -y"));
3756        assert!(df.contains("vim git"));
3757        assert!(!df.contains("apt-get"));
3758        assert!(!df.contains("rm -rf /var/lib/apt/lists"));
3759    }
3760
3761    #[test]
3762    fn build_dockerfile_archlinux() {
3763        let df = build_dockerfile("archlinux:latest", &["base-devel"]);
3764        assert!(df.contains("FROM archlinux:latest"));
3765        assert!(df.contains("pacman -Sy --noconfirm"));
3766        assert!(df.contains("base-devel"));
3767    }
3768
3769    #[test]
3770    fn build_dockerfile_rockylinux() {
3771        let df = build_dockerfile("rockylinux:9", &["httpd"]);
3772        assert!(df.contains("FROM rockylinux:9"));
3773        assert!(df.contains("dnf install -y"));
3774        assert!(df.contains("httpd"));
3775    }
3776
3777    #[test]
3778    fn build_dockerfile_almalinux() {
3779        let df = build_dockerfile("almalinux:8", &["nginx"]);
3780        assert!(df.contains("FROM almalinux:8"));
3781        assert!(df.contains("dnf install -y"));
3782    }
3783
3784    #[test]
3785    fn build_dockerfile_debian_cleans_apt_lists() {
3786        let df = build_dockerfile("debian:bookworm", &["curl"]);
3787        assert!(df.contains("FROM debian:bookworm"));
3788        assert!(df.contains("apt-get update && apt-get install -y"));
3789        assert!(
3790            df.contains("rm -rf /var/lib/apt/lists"),
3791            "debian-based images should clean apt lists"
3792        );
3793    }
3794
3795    #[test]
3796    fn build_dockerfile_always_includes_workdir_and_copy() {
3797        // Even with no packages, WORKDIR and COPY lines should be present
3798        let df = build_dockerfile("scratch", &[]);
3799        assert!(df.contains("WORKDIR /build"));
3800        assert!(df.contains("COPY . /build/"));
3801        assert!(!df.contains("RUN"));
3802    }
3803
3804    #[test]
3805    fn build_dockerfile_registry_prefixed_alpine() {
3806        // Images with registry prefix like "docker.io/library/alpine" should still be detected
3807        let df = build_dockerfile("docker.io/library/alpine:3.19", &["curl"]);
3808        assert!(df.contains("apk add --no-cache"));
3809        assert!(!df.contains("apt-get"));
3810    }
3811
3812    #[test]
3813    fn build_dockerfile_multiple_packages() {
3814        let df = build_dockerfile("ubuntu:22.04", &["curl", "jq", "vim", "git"]);
3815        assert!(df.contains("curl jq vim git"));
3816    }
3817
3818    // --- OCI index manifest structure ---
3819
3820    #[test]
3821    fn oci_index_serializes_with_correct_field_names() {
3822        let index = OciIndex {
3823            schema_version: 2,
3824            media_type: MEDIA_TYPE_OCI_INDEX.to_string(),
3825            manifests: vec![OciPlatformManifest {
3826                media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
3827                digest: "sha256:abc123".to_string(),
3828                size: 1024,
3829                platform: OciPlatform {
3830                    os: "linux".to_string(),
3831                    architecture: "amd64".to_string(),
3832                },
3833            }],
3834        };
3835
3836        let json_str = serde_json::to_string_pretty(&index).unwrap();
3837        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
3838
3839        // Verify camelCase field names (from serde rename_all)
3840        assert_eq!(parsed["schemaVersion"], 2);
3841        assert_eq!(
3842            parsed["mediaType"],
3843            "application/vnd.oci.image.index.v1+json"
3844        );
3845        assert!(parsed["manifests"].is_array());
3846        assert_eq!(parsed["manifests"][0]["size"], 1024);
3847        assert_eq!(parsed["manifests"][0]["digest"], "sha256:abc123");
3848        assert_eq!(parsed["manifests"][0]["platform"]["os"], "linux");
3849        assert_eq!(parsed["manifests"][0]["platform"]["architecture"], "amd64");
3850    }
3851
3852    #[test]
3853    fn oci_index_roundtrips_multiple_platforms() {
3854        let index = OciIndex {
3855            schema_version: 2,
3856            media_type: MEDIA_TYPE_OCI_INDEX.to_string(),
3857            manifests: vec![
3858                OciPlatformManifest {
3859                    media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
3860                    digest: "sha256:aaa".to_string(),
3861                    size: 100,
3862                    platform: OciPlatform {
3863                        os: "linux".to_string(),
3864                        architecture: "amd64".to_string(),
3865                    },
3866                },
3867                OciPlatformManifest {
3868                    media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
3869                    digest: "sha256:bbb".to_string(),
3870                    size: 200,
3871                    platform: OciPlatform {
3872                        os: "linux".to_string(),
3873                        architecture: "arm64".to_string(),
3874                    },
3875                },
3876                OciPlatformManifest {
3877                    media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
3878                    digest: "sha256:ccc".to_string(),
3879                    size: 300,
3880                    platform: OciPlatform {
3881                        os: "darwin".to_string(),
3882                        architecture: "arm64".to_string(),
3883                    },
3884                },
3885            ],
3886        };
3887
3888        let json_bytes = serde_json::to_vec(&index).unwrap();
3889        let roundtripped: OciIndex = serde_json::from_slice(&json_bytes).unwrap();
3890
3891        assert_eq!(roundtripped.schema_version, 2);
3892        assert_eq!(roundtripped.manifests.len(), 3);
3893        assert_eq!(roundtripped.manifests[0].platform.os, "linux");
3894        assert_eq!(roundtripped.manifests[0].platform.architecture, "amd64");
3895        assert_eq!(roundtripped.manifests[0].digest, "sha256:aaa");
3896        assert_eq!(roundtripped.manifests[0].size, 100);
3897        assert_eq!(roundtripped.manifests[1].platform.architecture, "arm64");
3898        assert_eq!(roundtripped.manifests[1].digest, "sha256:bbb");
3899        assert_eq!(roundtripped.manifests[2].platform.os, "darwin");
3900        assert_eq!(roundtripped.manifests[2].digest, "sha256:ccc");
3901        assert_eq!(roundtripped.manifests[2].size, 300);
3902    }
3903
3904    #[test]
3905    fn oci_index_empty_manifests_list() {
3906        let index = OciIndex {
3907            schema_version: 2,
3908            media_type: MEDIA_TYPE_OCI_INDEX.to_string(),
3909            manifests: vec![],
3910        };
3911
3912        let json_str = serde_json::to_string(&index).unwrap();
3913        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
3914        assert_eq!(parsed["manifests"].as_array().unwrap().len(), 0);
3915
3916        let roundtripped: OciIndex = serde_json::from_str(&json_str).unwrap();
3917        assert_eq!(roundtripped.manifests.len(), 0);
3918    }
3919
3920    // --- RegistryAuth basic_auth_header ---
3921
3922    #[test]
3923    fn registry_auth_basic_auth_header_format() {
3924        let auth = RegistryAuth {
3925            username: "testuser".to_string(),
3926            password: "testpass".to_string(),
3927        };
3928        let header = auth.basic_auth_header();
3929        assert!(header.starts_with("Basic "));
3930        // "testuser:testpass" base64 = "dGVzdHVzZXI6dGVzdHBhc3M="
3931        let encoded_part = header.strip_prefix("Basic ").unwrap();
3932        let decoded = base64_decode(encoded_part).unwrap();
3933        let decoded_str = String::from_utf8(decoded).unwrap();
3934        assert_eq!(decoded_str, "testuser:testpass");
3935    }
3936
3937    #[test]
3938    fn registry_auth_basic_auth_header_special_chars() {
3939        let auth = RegistryAuth {
3940            username: "user".to_string(),
3941            password: "p@ss:w0rd!".to_string(),
3942        };
3943        let header = auth.basic_auth_header();
3944        let encoded_part = header.strip_prefix("Basic ").unwrap();
3945        let decoded = base64_decode(encoded_part).unwrap();
3946        let decoded_str = String::from_utf8(decoded).unwrap();
3947        assert_eq!(decoded_str, "user:p@ss:w0rd!");
3948    }
3949
3950    // --- base64 edge cases ---
3951
3952    #[test]
3953    fn base64_encode_single_byte() {
3954        let encoded = base64_encode(b"a");
3955        let decoded = base64_decode(&encoded).unwrap();
3956        assert_eq!(decoded, b"a");
3957    }
3958
3959    #[test]
3960    fn base64_encode_two_bytes() {
3961        let encoded = base64_encode(b"ab");
3962        let decoded = base64_decode(&encoded).unwrap();
3963        assert_eq!(decoded, b"ab");
3964    }
3965
3966    #[test]
3967    fn base64_encode_three_bytes() {
3968        // Three bytes = no padding needed
3969        let encoded = base64_encode(b"abc");
3970        assert!(!encoded.contains('='));
3971        let decoded = base64_decode(&encoded).unwrap();
3972        assert_eq!(decoded, b"abc");
3973    }
3974
3975    #[test]
3976    fn base64_encode_binary_data() {
3977        let data: Vec<u8> = (0..=255).collect();
3978        let encoded = base64_encode(&data);
3979        let decoded = base64_decode(&encoded).unwrap();
3980        assert_eq!(decoded, data);
3981    }
3982
3983    #[test]
3984    fn base64_decode_with_whitespace() {
3985        // base64_decode trims whitespace
3986        let decoded = base64_decode("  dXNlcjpwYXNz  ").unwrap();
3987        assert_eq!(String::from_utf8(decoded).unwrap(), "user:pass");
3988    }
3989
3990    #[test]
3991    fn base64_decode_invalid_length() {
3992        // Non-multiple-of-4 length should fail
3993        assert!(base64_decode("abc").is_none());
3994        assert!(base64_decode("abcde").is_none());
3995    }
3996
3997    #[test]
3998    fn base64_decode_invalid_chars() {
3999        // Invalid base64 characters should fail
4000        assert!(base64_decode("ab~d").is_none());
4001    }
4002
4003    // --- OciReference::parse edge cases ---
4004
4005    #[test]
4006    fn parse_reference_with_whitespace_fails() {
4007        assert!(OciReference::parse("ghcr.io/my repo:v1").is_err());
4008    }
4009
4010    #[test]
4011    fn parse_reference_with_control_chars_fails() {
4012        assert!(OciReference::parse("ghcr.io/repo\x00:v1").is_err());
4013    }
4014
4015    #[test]
4016    fn parse_reference_host_colon_port_only_fails() {
4017        // "host:5000" without a repo path is invalid (looks like port with no repo)
4018        assert!(OciReference::parse("host:5000").is_err());
4019    }
4020
4021    #[test]
4022    fn parse_reference_localhost_no_port() {
4023        let r = OciReference::parse("localhost/myrepo:v1").unwrap();
4024        assert_eq!(r.registry, "localhost");
4025        assert_eq!(r.repository, "myrepo");
4026        assert_eq!(r.reference, ReferenceKind::Tag("v1".to_string()));
4027    }
4028
4029    #[test]
4030    fn parse_reference_registry_with_port_and_nested_repo() {
4031        let r = OciReference::parse("myregistry.io:5000/org/repo:v2").unwrap();
4032        assert_eq!(r.registry, "myregistry.io:5000");
4033        assert_eq!(r.repository, "org/repo");
4034        assert_eq!(r.reference, ReferenceKind::Tag("v2".to_string()));
4035    }
4036
4037    #[test]
4038    fn parse_reference_127_0_0_1() {
4039        let r = OciReference::parse("127.0.0.1:5000/test:dev").unwrap();
4040        assert_eq!(r.registry, "127.0.0.1:5000");
4041        assert_eq!(r.repository, "test");
4042        // api_base should use http for 127.0.0.1
4043        assert!(r.api_base().starts_with("http://"));
4044    }
4045
4046    #[test]
4047    fn reference_str_tag() {
4048        let r = OciReference {
4049            registry: "ghcr.io".to_string(),
4050            repository: "test/mod".to_string(),
4051            reference: ReferenceKind::Tag("v1.0".to_string()),
4052        };
4053        assert_eq!(r.reference_str(), "v1.0");
4054    }
4055
4056    #[test]
4057    fn reference_str_digest() {
4058        let r = OciReference {
4059            registry: "ghcr.io".to_string(),
4060            repository: "test/mod".to_string(),
4061            reference: ReferenceKind::Digest("sha256:abc".to_string()),
4062        };
4063        assert_eq!(r.reference_str(), "sha256:abc");
4064    }
4065
4066    // --- docker_config_path ---
4067
4068    #[test]
4069    fn docker_config_path_uses_env() {
4070        let prev = std::env::var("DOCKER_CONFIG").ok();
4071
4072        let tmp = tempfile::tempdir().unwrap();
4073        unsafe {
4074            std::env::set_var("DOCKER_CONFIG", tmp.path().to_str().unwrap());
4075        }
4076
4077        let path = docker_config_path();
4078        assert_eq!(path, tmp.path().join("config.json"));
4079
4080        unsafe {
4081            match prev {
4082                Some(v) => std::env::set_var("DOCKER_CONFIG", v),
4083                None => std::env::remove_var("DOCKER_CONFIG"),
4084            }
4085        }
4086    }
4087
4088    // --- create_tar_gz: symlinks skipped ---
4089
4090    #[test]
4091    #[cfg(unix)]
4092    fn create_tar_gz_skips_symlinks_in_source_dir() {
4093        let dir = tempfile::tempdir().unwrap();
4094        std::fs::write(dir.path().join("file.txt"), "content").unwrap();
4095        // Create a symlink
4096        std::os::unix::fs::symlink("/etc/passwd", dir.path().join("link")).unwrap();
4097
4098        let archive = create_tar_gz(dir.path()).unwrap();
4099
4100        // Extract and verify only the regular file exists
4101        let out = tempfile::tempdir().unwrap();
4102        extract_tar_gz(&archive, out.path()).unwrap();
4103
4104        assert!(out.path().join("file.txt").exists());
4105        // The symlink should not have been included in the archive
4106        assert!(
4107            !out.path().join("link").exists(),
4108            "symlinks should be skipped during archive creation"
4109        );
4110    }
4111
4112    // --- OciManifest with annotations ---
4113
4114    #[test]
4115    fn oci_manifest_with_annotations_round_trips() {
4116        let mut annotations = HashMap::new();
4117        annotations.insert("cfgd.io/platform".to_string(), "linux/amd64".to_string());
4118        annotations.insert(
4119            "org.opencontainers.image.created".to_string(),
4120            "2026-01-01T00:00:00Z".to_string(),
4121        );
4122
4123        let manifest = OciManifest {
4124            schema_version: 2,
4125            media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
4126            config: OciDescriptor {
4127                media_type: MEDIA_TYPE_MODULE_CONFIG.to_string(),
4128                digest: "sha256:cfg123".to_string(),
4129                size: 50,
4130                annotations: HashMap::new(),
4131            },
4132            layers: vec![OciDescriptor {
4133                media_type: MEDIA_TYPE_MODULE_LAYER.to_string(),
4134                digest: "sha256:layer123".to_string(),
4135                size: 1024,
4136                annotations: HashMap::new(),
4137            }],
4138            annotations,
4139        };
4140
4141        let json = serde_json::to_string_pretty(&manifest).unwrap();
4142        let parsed: OciManifest = serde_json::from_str(&json).unwrap();
4143        assert_eq!(parsed.annotations.len(), 2);
4144        assert_eq!(
4145            parsed.annotations.get("cfgd.io/platform").unwrap(),
4146            "linux/amd64"
4147        );
4148        assert_eq!(
4149            parsed
4150                .annotations
4151                .get("org.opencontainers.image.created")
4152                .unwrap(),
4153            "2026-01-01T00:00:00Z"
4154        );
4155    }
4156
4157    #[test]
4158    fn oci_manifest_empty_annotations_skipped_in_json() {
4159        let manifest = OciManifest {
4160            schema_version: 2,
4161            media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
4162            config: OciDescriptor {
4163                media_type: MEDIA_TYPE_MODULE_CONFIG.to_string(),
4164                digest: "sha256:cfg".to_string(),
4165                size: 10,
4166                annotations: HashMap::new(),
4167            },
4168            layers: vec![],
4169            annotations: HashMap::new(),
4170        };
4171
4172        let json = serde_json::to_string(&manifest).unwrap();
4173        // Empty HashMaps have skip_serializing_if = "HashMap::is_empty"
4174        assert!(
4175            !json.contains("annotations"),
4176            "empty annotations should be skipped in serialization"
4177        );
4178    }
4179
4180    // --- OciDescriptor with annotations ---
4181
4182    #[test]
4183    fn oci_descriptor_with_annotations_round_trips() {
4184        let mut anns = HashMap::new();
4185        anns.insert(
4186            "org.opencontainers.image.title".to_string(),
4187            "my-module".to_string(),
4188        );
4189
4190        let desc = OciDescriptor {
4191            media_type: MEDIA_TYPE_MODULE_LAYER.to_string(),
4192            digest: "sha256:abc".to_string(),
4193            size: 512,
4194            annotations: anns,
4195        };
4196
4197        let json = serde_json::to_string(&desc).unwrap();
4198        let parsed: OciDescriptor = serde_json::from_str(&json).unwrap();
4199        assert_eq!(parsed.size, 512);
4200        assert_eq!(
4201            parsed
4202                .annotations
4203                .get("org.opencontainers.image.title")
4204                .unwrap(),
4205            "my-module"
4206        );
4207    }
4208
4209    // --- generate_slsa_provenance: digest prefix stripping ---
4210
4211    #[test]
4212    fn generate_slsa_provenance_strips_sha256_prefix() {
4213        let prov = generate_slsa_provenance(
4214            "ghcr.io/test/mod:v1",
4215            "sha256:deadbeef1234",
4216            "https://github.com/org/repo",
4217            "abc123",
4218        )
4219        .unwrap();
4220        let parsed: serde_json::Value = serde_json::from_str(&prov).unwrap();
4221        // The sha256 prefix should be stripped from the digest value
4222        assert_eq!(parsed["subject"][0]["digest"]["sha256"], "deadbeef1234");
4223    }
4224
4225    #[test]
4226    fn generate_slsa_provenance_handles_plain_digest() {
4227        let prov = generate_slsa_provenance(
4228            "ghcr.io/test/mod:v1",
4229            "plaindigest",
4230            "https://github.com/org/repo",
4231            "abc123",
4232        )
4233        .unwrap();
4234        let parsed: serde_json::Value = serde_json::from_str(&prov).unwrap();
4235        // When no sha256: prefix, the whole string is used
4236        assert_eq!(parsed["subject"][0]["digest"]["sha256"], "plaindigest");
4237    }
4238
4239    #[test]
4240    fn generate_slsa_provenance_includes_source_info() {
4241        let prov = generate_slsa_provenance(
4242            "ghcr.io/myorg/mymod:v2",
4243            "sha256:abcdef",
4244            "https://github.com/myorg/myrepo",
4245            "deadbeef123",
4246        )
4247        .unwrap();
4248        let parsed: serde_json::Value = serde_json::from_str(&prov).unwrap();
4249        assert_eq!(parsed["_type"], "https://in-toto.io/Statement/v1");
4250        assert_eq!(
4251            parsed["predicate"]["buildDefinition"]["externalParameters"]["source"]["uri"],
4252            "https://github.com/myorg/myrepo"
4253        );
4254        assert_eq!(
4255            parsed["predicate"]["buildDefinition"]["externalParameters"]["source"]["digest"]["gitCommit"],
4256            "deadbeef123"
4257        );
4258        assert_eq!(
4259            parsed["predicate"]["runDetails"]["builder"]["id"],
4260            "https://cfgd.io/builder/v1"
4261        );
4262    }
4263
4264    // --- extract_auth_param edge cases ---
4265
4266    #[test]
4267    fn extract_auth_param_empty_value() {
4268        let header = r#"Bearer realm="",service="svc""#;
4269        assert_eq!(extract_auth_param(header, "realm"), Some(""));
4270    }
4271
4272    #[test]
4273    fn extract_auth_param_no_quotes() {
4274        let header = "Bearer realm_value=noquotes";
4275        assert_eq!(extract_auth_param(header, "realm"), None);
4276    }
4277
4278    #[test]
4279    fn extract_auth_param_url_with_special_chars() {
4280        let header = r#"Bearer realm="https://auth.example.com/token?foo=bar",service="svc""#;
4281        let realm = extract_auth_param(header, "realm").unwrap();
4282        assert_eq!(realm, "https://auth.example.com/token?foo=bar");
4283    }
4284
4285    // --- detect_pkg_install_cmd: container registry-prefixed images ---
4286
4287    #[test]
4288    fn detect_pkg_install_cmd_registry_prefixed_fedora() {
4289        assert_eq!(
4290            detect_pkg_install_cmd("registry.example.com/fedora:39"),
4291            "dnf install -y"
4292        );
4293    }
4294
4295    #[test]
4296    fn detect_pkg_install_cmd_registry_prefixed_centos() {
4297        assert_eq!(
4298            detect_pkg_install_cmd("quay.io/centos/centos:stream8"),
4299            "yum install -y"
4300        );
4301    }
4302
4303    #[test]
4304    fn detect_pkg_install_cmd_registry_prefixed_archlinux() {
4305        assert_eq!(
4306            detect_pkg_install_cmd("docker.io/library/archlinux:base"),
4307            "pacman -Sy --noconfirm"
4308        );
4309    }
4310
4311    // --- DockerConfig deserialization ---
4312
4313    #[test]
4314    fn docker_config_empty_json() {
4315        let config: DockerConfig = serde_json::from_str("{}").unwrap();
4316        assert!(config.auths.is_empty());
4317        assert!(config.cred_helpers.is_empty());
4318    }
4319
4320    #[test]
4321    fn docker_config_with_only_cred_helpers() {
4322        let json = r#"{"credHelpers":{"gcr.io":"gcloud"}}"#;
4323        let config: DockerConfig = serde_json::from_str(json).unwrap();
4324        assert!(config.auths.is_empty());
4325        assert_eq!(config.cred_helpers.len(), 1);
4326        assert_eq!(config.cred_helpers.get("gcr.io").unwrap(), "gcloud");
4327    }
4328
4329    // --- create_tar_gz with nested directories ---
4330
4331    #[test]
4332    fn create_tar_gz_nested_dirs() {
4333        let dir = tempfile::tempdir().unwrap();
4334        let deep = dir.path().join("a/b/c");
4335        std::fs::create_dir_all(&deep).unwrap();
4336        std::fs::write(deep.join("deep.txt"), "nested content").unwrap();
4337        std::fs::write(dir.path().join("root.txt"), "top level").unwrap();
4338
4339        let archive = create_tar_gz(dir.path()).unwrap();
4340
4341        let out = tempfile::tempdir().unwrap();
4342        extract_tar_gz(&archive, out.path()).unwrap();
4343
4344        let root_content = std::fs::read_to_string(out.path().join("root.txt")).unwrap();
4345        assert_eq!(root_content, "top level");
4346
4347        let deep_content = std::fs::read_to_string(out.path().join("a/b/c/deep.txt")).unwrap();
4348        assert_eq!(deep_content, "nested content");
4349    }
4350
4351    // --- create_tar_gz empty directory ---
4352
4353    #[test]
4354    fn create_tar_gz_empty_dir() {
4355        let dir = tempfile::tempdir().unwrap();
4356        let archive = create_tar_gz(dir.path()).unwrap();
4357        assert!(
4358            !archive.is_empty(),
4359            "even empty dir should produce a valid gzip stream"
4360        );
4361
4362        let out = tempfile::tempdir().unwrap();
4363        let result = extract_tar_gz(&archive, out.path());
4364        assert!(result.is_ok());
4365    }
4366
4367    // --- is_insecure_registry: no env var set ---
4368
4369    #[test]
4370    fn is_insecure_registry_false_when_env_not_set() {
4371        // With the env var not containing our test registry, it should be false
4372        // (we cannot safely unset env vars in parallel tests, but the default
4373        // is empty which means no registry is insecure)
4374        assert!(!is_insecure_registry("totally-not-insecure.example.com"));
4375    }
4376
4377    // --- OciReference Display for various formats ---
4378
4379    #[test]
4380    fn oci_reference_display_tag() {
4381        let r = OciReference {
4382            registry: "registry.example.com".to_string(),
4383            repository: "org/repo".to_string(),
4384            reference: ReferenceKind::Tag("v1.2.3".to_string()),
4385        };
4386        assert_eq!(format!("{}", r), "registry.example.com/org/repo:v1.2.3");
4387    }
4388
4389    #[test]
4390    fn oci_reference_display_digest() {
4391        let r = OciReference {
4392            registry: "ghcr.io".to_string(),
4393            repository: "myorg/mymod".to_string(),
4394            reference: ReferenceKind::Digest("sha256:abcdef1234".to_string()),
4395        };
4396        assert_eq!(format!("{}", r), "ghcr.io/myorg/mymod@sha256:abcdef1234");
4397    }
4398
4399    // --- media type constants ---
4400
4401    #[test]
4402    fn media_type_constants_are_correct() {
4403        assert_eq!(
4404            MEDIA_TYPE_MODULE_CONFIG,
4405            "application/vnd.cfgd.module.config.v1+json"
4406        );
4407        assert_eq!(
4408            MEDIA_TYPE_MODULE_LAYER,
4409            "application/vnd.cfgd.module.layer.v1.tar+gzip"
4410        );
4411        assert_eq!(
4412            MEDIA_TYPE_OCI_MANIFEST,
4413            "application/vnd.oci.image.manifest.v1+json"
4414        );
4415        assert_eq!(
4416            MEDIA_TYPE_OCI_INDEX,
4417            "application/vnd.oci.image.index.v1+json"
4418        );
4419    }
4420
4421    // --- sha256_digest determinism ---
4422
4423    #[test]
4424    fn sha256_digest_deterministic() {
4425        let data = b"test data for determinism check";
4426        let d1 = sha256_digest(data);
4427        let d2 = sha256_digest(data);
4428        assert_eq!(d1, d2);
4429    }
4430
4431    #[test]
4432    fn sha256_digest_different_inputs_different_outputs() {
4433        let d1 = sha256_digest(b"input one");
4434        let d2 = sha256_digest(b"input two");
4435        assert_ne!(d1, d2);
4436    }
4437
4438    // --- build_dockerfile: comprehensive generation ---
4439
4440    #[test]
4441    fn build_dockerfile_ubuntu_with_packages_cleans_apt_lists() {
4442        let df = build_dockerfile("ubuntu:24.04", &["git", "curl", "make"]);
4443        assert_eq!(df.lines().count(), 4, "FROM + RUN + WORKDIR + COPY");
4444        assert!(df.starts_with("FROM ubuntu:24.04"));
4445        assert!(df.contains("apt-get update && apt-get install -y git curl make"));
4446        assert!(df.contains("rm -rf /var/lib/apt/lists/*"));
4447        assert!(df.contains("WORKDIR /build"));
4448        assert!(df.contains("COPY . /build/"));
4449    }
4450
4451    #[test]
4452    fn build_dockerfile_debian_uses_apt() {
4453        let df = build_dockerfile("debian:bookworm", &["vim"]);
4454        assert!(df.contains("apt-get"), "debian should use apt-get");
4455        assert!(df.contains("rm -rf /var/lib/apt/lists/*"));
4456    }
4457
4458    #[test]
4459    fn build_dockerfile_alpine_uses_apk() {
4460        let df = build_dockerfile("alpine:3.20", &["bash", "coreutils"]);
4461        assert!(df.contains("apk add --no-cache bash coreutils"));
4462        assert!(!df.contains("apt-get"));
4463        assert!(!df.contains("rm -rf"));
4464    }
4465
4466    #[test]
4467    fn build_dockerfile_fedora_uses_dnf() {
4468        let df = build_dockerfile("fedora:41", &["gcc", "gdb"]);
4469        assert!(df.contains("dnf install -y gcc gdb"));
4470    }
4471
4472    #[test]
4473    fn build_dockerfile_rockylinux_uses_dnf() {
4474        let df = build_dockerfile("rockylinux:9.3", &["python3"]);
4475        assert!(df.contains("dnf install -y python3"));
4476    }
4477
4478    #[test]
4479    fn build_dockerfile_almalinux_uses_dnf() {
4480        let df = build_dockerfile("almalinux:9", &["wget"]);
4481        assert!(df.contains("dnf install -y wget"));
4482    }
4483
4484    #[test]
4485    fn build_dockerfile_centos_uses_yum() {
4486        let df = build_dockerfile("centos:7", &["nmap"]);
4487        assert!(df.contains("yum install -y nmap"));
4488    }
4489
4490    #[test]
4491    fn build_dockerfile_archlinux_uses_pacman() {
4492        let df = build_dockerfile("archlinux:base", &["neovim"]);
4493        assert!(df.contains("pacman -Sy --noconfirm neovim"));
4494    }
4495
4496    #[test]
4497    fn build_dockerfile_registry_prefixed_alpine_custom() {
4498        let df = build_dockerfile("ghcr.io/custom/alpine:edge", &["jq"]);
4499        assert!(df.contains("apk add --no-cache jq"));
4500        assert!(df.starts_with("FROM ghcr.io/custom/alpine:edge"));
4501    }
4502
4503    #[test]
4504    fn build_dockerfile_registry_prefixed_fedora() {
4505        let df = build_dockerfile("quay.io/fedora/fedora:40", &["strace"]);
4506        assert!(df.contains("dnf install -y strace"));
4507    }
4508
4509    #[test]
4510    fn build_dockerfile_empty_packages_has_no_run() {
4511        let df = build_dockerfile("scratch", &[]);
4512        assert_eq!(df.lines().count(), 3, "FROM + WORKDIR + COPY, no RUN");
4513        assert!(!df.contains("RUN"));
4514    }
4515
4516    #[test]
4517    fn build_dockerfile_single_package() {
4518        let df = build_dockerfile("ubuntu:22.04", &["curl"]);
4519        assert!(df.contains("curl"));
4520        // Should only have one RUN line
4521        let run_count = df.lines().filter(|l| l.starts_with("RUN")).count();
4522        assert_eq!(run_count, 1);
4523    }
4524
4525    // --- detect_pkg_install_cmd: all mappings ---
4526
4527    #[test]
4528    fn detect_pkg_install_cmd_alpine() {
4529        assert_eq!(detect_pkg_install_cmd("alpine:3.19"), "apk add --no-cache");
4530    }
4531
4532    #[test]
4533    fn detect_pkg_install_cmd_ubuntu() {
4534        let cmd = detect_pkg_install_cmd("ubuntu:22.04");
4535        assert!(
4536            cmd.starts_with("apt-get"),
4537            "ubuntu should use apt-get: {cmd}"
4538        );
4539    }
4540
4541    #[test]
4542    fn detect_pkg_install_cmd_debian() {
4543        let cmd = detect_pkg_install_cmd("debian:12");
4544        assert!(
4545            cmd.starts_with("apt-get"),
4546            "debian should use apt-get: {cmd}"
4547        );
4548    }
4549
4550    #[test]
4551    fn detect_pkg_install_cmd_case_insensitive() {
4552        // The function lowercases the image name
4553        assert_eq!(detect_pkg_install_cmd("ALPINE:3.19"), "apk add --no-cache");
4554        assert_eq!(detect_pkg_install_cmd("Fedora:39"), "dnf install -y");
4555    }
4556
4557    // --- generate_slsa_provenance: detailed JSON structure ---
4558
4559    #[test]
4560    fn generate_slsa_provenance_complete_structure() {
4561        let prov = generate_slsa_provenance(
4562            "ghcr.io/org/mod:v2.0.0",
4563            "sha256:deadbeef1234",
4564            "https://github.com/org/config",
4565            "abc123def456",
4566        )
4567        .unwrap();
4568
4569        let parsed: serde_json::Value = serde_json::from_str(&prov).unwrap();
4570
4571        // Top-level fields
4572        assert_eq!(
4573            parsed["_type"], "https://in-toto.io/Statement/v1",
4574            "should be in-toto v1 statement"
4575        );
4576        assert_eq!(
4577            parsed["predicateType"], "https://slsa.dev/provenance/v1",
4578            "should be SLSA v1 provenance"
4579        );
4580
4581        // Subject
4582        let subject = &parsed["subject"][0];
4583        assert_eq!(subject["name"], "ghcr.io/org/mod:v2.0.0");
4584        assert_eq!(
4585            subject["digest"]["sha256"], "deadbeef1234",
4586            "sha256: prefix should be stripped"
4587        );
4588
4589        // Predicate
4590        let predicate = &parsed["predicate"];
4591        assert_eq!(
4592            predicate["buildDefinition"]["buildType"],
4593            "https://cfgd.io/ModuleBuild/v1"
4594        );
4595        assert_eq!(
4596            predicate["buildDefinition"]["externalParameters"]["source"]["uri"],
4597            "https://github.com/org/config"
4598        );
4599        assert_eq!(
4600            predicate["buildDefinition"]["externalParameters"]["source"]["digest"]["gitCommit"],
4601            "abc123def456"
4602        );
4603        assert_eq!(
4604            predicate["runDetails"]["builder"]["id"],
4605            "https://cfgd.io/builder/v1"
4606        );
4607
4608        // Metadata timestamps should be non-empty
4609        let invocation_id = predicate["runDetails"]["metadata"]["invocationId"]
4610            .as_str()
4611            .unwrap();
4612        assert!(
4613            !invocation_id.is_empty(),
4614            "invocationId should not be empty"
4615        );
4616    }
4617
4618    #[test]
4619    fn generate_slsa_provenance_strips_sha256_prefix_v2() {
4620        let prov =
4621            generate_slsa_provenance("ghcr.io/test:v1", "sha256:abcdef", "repo", "commit").unwrap();
4622        let parsed: serde_json::Value = serde_json::from_str(&prov).unwrap();
4623        assert_eq!(
4624            parsed["subject"][0]["digest"]["sha256"], "abcdef",
4625            "sha256: prefix should be stripped from digest"
4626        );
4627    }
4628
4629    #[test]
4630    fn generate_slsa_provenance_bare_digest() {
4631        let prov = generate_slsa_provenance("ghcr.io/test:v1", "abcdef", "repo", "commit").unwrap();
4632        let parsed: serde_json::Value = serde_json::from_str(&prov).unwrap();
4633        assert_eq!(
4634            parsed["subject"][0]["digest"]["sha256"], "abcdef",
4635            "bare digest should pass through as-is"
4636        );
4637    }
4638
4639    // --- OCI manifest construction ---
4640
4641    #[test]
4642    fn oci_manifest_round_trip_with_annotations() {
4643        let mut annotations = HashMap::new();
4644        annotations.insert("cfgd.io/platform".to_string(), "linux/amd64".to_string());
4645        annotations.insert(
4646            "org.opencontainers.image.created".to_string(),
4647            "2026-01-01T00:00:00Z".to_string(),
4648        );
4649
4650        let manifest = OciManifest {
4651            schema_version: 2,
4652            media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
4653            config: OciDescriptor {
4654                media_type: MEDIA_TYPE_MODULE_CONFIG.to_string(),
4655                digest: "sha256:configdigest123".to_string(),
4656                size: 512,
4657                annotations: HashMap::new(),
4658            },
4659            layers: vec![OciDescriptor {
4660                media_type: MEDIA_TYPE_MODULE_LAYER.to_string(),
4661                digest: "sha256:layer1digest".to_string(),
4662                size: 4096,
4663                annotations: HashMap::new(),
4664            }],
4665            annotations: annotations.clone(),
4666        };
4667
4668        let json = serde_json::to_string_pretty(&manifest).unwrap();
4669        let parsed: OciManifest = serde_json::from_str(&json).unwrap();
4670
4671        assert_eq!(parsed.schema_version, 2);
4672        assert_eq!(parsed.media_type, MEDIA_TYPE_OCI_MANIFEST);
4673        assert_eq!(parsed.config.size, 512);
4674        assert_eq!(parsed.layers.len(), 1);
4675        assert_eq!(parsed.layers[0].size, 4096);
4676        assert_eq!(parsed.annotations.len(), 2);
4677        assert_eq!(
4678            parsed.annotations.get("cfgd.io/platform").unwrap(),
4679            "linux/amd64"
4680        );
4681    }
4682
4683    #[test]
4684    fn oci_manifest_camel_case_keys() {
4685        let manifest = OciManifest {
4686            schema_version: 2,
4687            media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
4688            config: OciDescriptor {
4689                media_type: MEDIA_TYPE_MODULE_CONFIG.to_string(),
4690                digest: "sha256:abc".to_string(),
4691                size: 100,
4692                annotations: HashMap::new(),
4693            },
4694            layers: vec![],
4695            annotations: HashMap::new(),
4696        };
4697
4698        let json = serde_json::to_string(&manifest).unwrap();
4699        assert!(
4700            json.contains("\"schemaVersion\""),
4701            "should use camelCase: {json}"
4702        );
4703        assert!(
4704            json.contains("\"mediaType\""),
4705            "should use camelCase: {json}"
4706        );
4707        assert!(
4708            !json.contains("\"schema_version\""),
4709            "should not use snake_case: {json}"
4710        );
4711    }
4712
4713    // --- Docker config parsing: additional coverage ---
4714
4715    #[test]
4716    fn docker_config_with_empty_auth_field() {
4717        let config_json = r#"{
4718            "auths": {
4719                "ghcr.io": {
4720                    "auth": ""
4721                }
4722            }
4723        }"#;
4724        let config: DockerConfig = serde_json::from_str(config_json).unwrap();
4725        // Empty auth string should decode to empty Vec, which has no colon, so None
4726        let auth = resolve_from_docker_auths(&config.auths, "ghcr.io");
4727        assert!(auth.is_none(), "empty auth field should not resolve");
4728    }
4729
4730    #[test]
4731    fn docker_config_index_docker_io_fallback() {
4732        let config_json = r#"{
4733            "auths": {
4734                "index.docker.io": {
4735                    "auth": "dXNlcjpwYXNz"
4736                }
4737            }
4738        }"#;
4739        let config: DockerConfig = serde_json::from_str(config_json).unwrap();
4740        // Querying for "docker.io" should fall back to checking index.docker.io
4741        let auth = resolve_from_docker_auths(&config.auths, "docker.io");
4742        assert!(
4743            auth.is_some(),
4744            "docker.io should fall back to index.docker.io"
4745        );
4746        assert_eq!(auth.unwrap().username, "user");
4747    }
4748
4749    #[test]
4750    fn docker_config_v1_suffix_match() {
4751        let config_json = r#"{
4752            "auths": {
4753                "https://ghcr.io/v1/": {
4754                    "auth": "dXNlcjpwYXNz"
4755                }
4756            }
4757        }"#;
4758        let config: DockerConfig = serde_json::from_str(config_json).unwrap();
4759        let auth = resolve_from_docker_auths(&config.auths, "ghcr.io");
4760        assert!(auth.is_some(), "should match https://ghcr.io/v1/");
4761    }
4762
4763    // --- base64 edge cases ---
4764
4765    #[test]
4766    fn base64_encode_padding_one_byte() {
4767        // "a" -> "YQ==" (requires 2 padding chars)
4768        let encoded = base64_encode(b"a");
4769        assert_eq!(encoded, "YQ==");
4770        let decoded = base64_decode(&encoded).unwrap();
4771        assert_eq!(decoded, b"a");
4772    }
4773
4774    #[test]
4775    fn base64_encode_padding_two_bytes() {
4776        // "ab" -> "YWI=" (requires 1 padding char)
4777        let encoded = base64_encode(b"ab");
4778        assert_eq!(encoded, "YWI=");
4779        let decoded = base64_decode(&encoded).unwrap();
4780        assert_eq!(decoded, b"ab");
4781    }
4782
4783    #[test]
4784    fn base64_encode_no_padding_three_bytes() {
4785        // "abc" -> "YWJj" (no padding needed)
4786        let encoded = base64_encode(b"abc");
4787        assert_eq!(encoded, "YWJj");
4788        let decoded = base64_decode(&encoded).unwrap();
4789        assert_eq!(decoded, b"abc");
4790    }
4791
4792    #[test]
4793    fn base64_decode_invalid_length_short() {
4794        // Not a multiple of 4
4795        let result = base64_decode("abc");
4796        assert!(result.is_none(), "invalid length should fail");
4797    }
4798
4799    #[test]
4800    fn base64_decode_invalid_chars_special() {
4801        let result = base64_decode("!!!!");
4802        assert!(result.is_none(), "invalid base64 characters should fail");
4803    }
4804
4805    #[test]
4806    fn base64_roundtrip_binary_data() {
4807        let data: Vec<u8> = (0..=255).collect();
4808        let encoded = base64_encode(&data);
4809        let decoded = base64_decode(&encoded).unwrap();
4810        assert_eq!(decoded, data);
4811    }
4812
4813    // --- RegistryAuth basic_auth_header ---
4814
4815    #[test]
4816    fn registry_auth_basic_header_format() {
4817        let auth = RegistryAuth {
4818            username: "myuser".to_string(),
4819            password: "mypass".to_string(),
4820        };
4821        let header = auth.basic_auth_header();
4822        assert!(header.starts_with("Basic "), "should start with 'Basic '");
4823
4824        // Decode and verify
4825        let encoded = header.strip_prefix("Basic ").unwrap();
4826        let decoded = base64_decode(encoded).unwrap();
4827        let cred = String::from_utf8(decoded).unwrap();
4828        assert_eq!(cred, "myuser:mypass");
4829    }
4830
4831    // --- OCI reference edge cases ---
4832
4833    #[test]
4834    fn parse_reference_whitespace_rejected() {
4835        assert!(OciReference::parse("ghcr.io/repo name:v1").is_err());
4836        assert!(OciReference::parse("ghcr.io/repo\tname:v1").is_err());
4837    }
4838
4839    #[test]
4840    fn parse_reference_host_port_no_repo_rejected() {
4841        // "localhost:5000" without a repo path and with a numeric "tag" = port
4842        let result = OciReference::parse("localhost:5000");
4843        // This should be treated as host:port with no repo
4844        assert!(
4845            result.is_err(),
4846            "bare host:port without repo should be rejected, got: {result:?}"
4847        );
4848    }
4849
4850    #[test]
4851    fn reference_str_returns_tag_or_digest() {
4852        let tag_ref = OciReference {
4853            registry: "ghcr.io".to_string(),
4854            repository: "test".to_string(),
4855            reference: ReferenceKind::Tag("v1.0.0".to_string()),
4856        };
4857        assert_eq!(tag_ref.reference_str(), "v1.0.0");
4858
4859        let digest_ref = OciReference {
4860            registry: "ghcr.io".to_string(),
4861            repository: "test".to_string(),
4862            reference: ReferenceKind::Digest("sha256:abc".to_string()),
4863        };
4864        assert_eq!(digest_ref.reference_str(), "sha256:abc");
4865    }
4866
4867    // --- tar gz with symlinks skipped ---
4868
4869    #[test]
4870    fn create_tar_gz_skips_symlinks() {
4871        let dir = tempfile::tempdir().unwrap();
4872        std::fs::write(dir.path().join("real.txt"), "real content").unwrap();
4873        #[cfg(unix)]
4874        std::os::unix::fs::symlink("real.txt", dir.path().join("link.txt")).unwrap();
4875
4876        let archive = create_tar_gz(dir.path()).unwrap();
4877        assert!(!archive.is_empty());
4878
4879        // Extract and verify symlink was skipped
4880        let out = tempfile::tempdir().unwrap();
4881        extract_tar_gz(&archive, out.path()).unwrap();
4882        assert!(out.path().join("real.txt").exists());
4883        // Symlink should NOT be in the extracted output
4884        assert!(
4885            !out.path().join("link.txt").exists(),
4886            "symlinks should be skipped in create_tar_gz"
4887        );
4888    }
4889
4890    // --- extract_tar_gz rejects symlinks ---
4891
4892    #[test]
4893    fn extract_tar_gz_skips_symlinks_in_archive() {
4894        // Build a tar.gz that contains a symlink entry
4895        use flate2::write::GzEncoder;
4896
4897        let buf = Vec::new();
4898        let encoder = GzEncoder::new(buf, flate2::Compression::default());
4899        let mut archive = tar::Builder::new(encoder);
4900
4901        // Add a normal file
4902        let content = b"normal file";
4903        let mut header = tar::Header::new_gnu();
4904        header.set_size(content.len() as u64);
4905        header.set_mode(0o644);
4906        header.set_cksum();
4907        archive
4908            .append_data(&mut header, "normal.txt", &content[..])
4909            .unwrap();
4910
4911        // Add a symlink entry
4912        let mut sym_header = tar::Header::new_gnu();
4913        sym_header.set_entry_type(tar::EntryType::Symlink);
4914        sym_header.set_size(0);
4915        sym_header.set_mode(0o777);
4916        sym_header.set_cksum();
4917        archive
4918            .append_link(&mut sym_header, "evil-link", "/etc/passwd")
4919            .unwrap();
4920
4921        let encoder = archive.into_inner().unwrap();
4922        let compressed = encoder.finish().unwrap();
4923
4924        let out = tempfile::tempdir().unwrap();
4925        extract_tar_gz(&compressed, out.path()).unwrap();
4926
4927        assert!(out.path().join("normal.txt").exists());
4928        assert!(
4929            !out.path().join("evil-link").exists(),
4930            "symlinks should be skipped during extraction"
4931        );
4932    }
4933
4934    // --- extract_auth_param edge cases ---
4935
4936    #[test]
4937    fn extract_auth_param_missing_param() {
4938        let header = r#"Bearer realm="https://auth.example.com/token""#;
4939        assert!(extract_auth_param(header, "service").is_none());
4940        assert!(extract_auth_param(header, "scope").is_none());
4941        assert!(extract_auth_param(header, "realm").is_some());
4942    }
4943
4944    #[test]
4945    fn extract_auth_param_empty_value_parsed() {
4946        let header = r#"Bearer realm="",service="svc""#;
4947        assert_eq!(extract_auth_param(header, "realm"), Some(""));
4948        assert_eq!(extract_auth_param(header, "service"), Some("svc"));
4949    }
4950
4951    // --- validate_verify_options ---
4952
4953    #[test]
4954    fn validate_verify_options_key_only_passes() {
4955        let opts = VerifyOptions {
4956            key: Some("cosign.pub"),
4957            identity: None,
4958            issuer: None,
4959        };
4960        assert!(validate_verify_options(&opts).is_ok());
4961    }
4962
4963    #[test]
4964    fn validate_verify_options_identity_only_passes() {
4965        let opts = VerifyOptions {
4966            key: None,
4967            identity: Some("user@example.com"),
4968            issuer: None,
4969        };
4970        assert!(validate_verify_options(&opts).is_ok());
4971    }
4972
4973    #[test]
4974    fn validate_verify_options_issuer_only_passes() {
4975        let opts = VerifyOptions {
4976            key: None,
4977            identity: None,
4978            issuer: Some("https://accounts.google.com"),
4979        };
4980        assert!(validate_verify_options(&opts).is_ok());
4981    }
4982
4983    #[test]
4984    fn validate_verify_options_all_none_fails() {
4985        let opts = VerifyOptions {
4986            key: None,
4987            identity: None,
4988            issuer: None,
4989        };
4990        let result = validate_verify_options(&opts);
4991        assert!(result.is_err());
4992        let msg = format!("{}", result.unwrap_err());
4993        assert!(
4994            msg.contains("keyless verification requires"),
4995            "should explain the requirement: {msg}"
4996        );
4997    }
4998
4999    // --- is_insecure_registry ---
5000
5001    #[test]
5002    fn is_insecure_registry_without_env_var() {
5003        // When OCI_INSECURE_REGISTRIES is not set (or empty), nothing is insecure
5004        let prev = std::env::var("OCI_INSECURE_REGISTRIES").ok();
5005        unsafe {
5006            std::env::remove_var("OCI_INSECURE_REGISTRIES");
5007        }
5008        assert!(!is_insecure_registry("localhost:5000"));
5009        assert!(!is_insecure_registry("ghcr.io"));
5010
5011        // Restore
5012        unsafe {
5013            if let Some(v) = prev {
5014                std::env::set_var("OCI_INSECURE_REGISTRIES", v);
5015            }
5016        }
5017    }
5018
5019    // --- OCI index round-trip with platform details ---
5020
5021    #[test]
5022    fn oci_index_camel_case_and_round_trip() {
5023        let index = OciIndex {
5024            schema_version: 2,
5025            media_type: MEDIA_TYPE_OCI_INDEX.to_string(),
5026            manifests: vec![OciPlatformManifest {
5027                media_type: MEDIA_TYPE_OCI_MANIFEST.to_string(),
5028                digest: "sha256:abc".to_string(),
5029                size: 100,
5030                platform: OciPlatform {
5031                    os: "linux".to_string(),
5032                    architecture: "amd64".to_string(),
5033                },
5034            }],
5035        };
5036
5037        let json = serde_json::to_string(&index).unwrap();
5038        assert!(json.contains("\"schemaVersion\""));
5039        assert!(json.contains("\"mediaType\""));
5040        assert!(!json.contains("\"schema_version\""));
5041
5042        let parsed: OciIndex = serde_json::from_str(&json).unwrap();
5043        assert_eq!(parsed.manifests[0].platform.os, "linux");
5044        assert_eq!(parsed.manifests[0].platform.architecture, "amd64");
5045    }
5046
5047    // --- decode_docker_auth: password with special chars ---
5048
5049    #[test]
5050    fn decode_docker_auth_with_token_password() {
5051        // Simulate a PAT token as password: "ghp_abc123xyz"
5052        let input = "user:ghp_abc123xyz";
5053        let encoded = base64_encode(input.as_bytes());
5054        let result = decode_docker_auth(&encoded);
5055        assert!(result.is_some());
5056        let auth = result.unwrap();
5057        assert_eq!(auth.username, "user");
5058        assert_eq!(auth.password, "ghp_abc123xyz");
5059    }
5060
5061    // --- apply_verify_args: verify args are set correctly ---
5062
5063    #[test]
5064    fn apply_verify_args_with_key_only() {
5065        let mut cmd = std::process::Command::new("echo");
5066        let opts = VerifyOptions {
5067            key: Some("/path/to/cosign.pub"),
5068            identity: None,
5069            issuer: None,
5070        };
5071        apply_verify_args(&mut cmd, &opts);
5072        // Can't easily inspect Command args, but verify it doesn't panic
5073    }
5074
5075    #[test]
5076    fn apply_verify_args_keyless_defaults() {
5077        let mut cmd = std::process::Command::new("echo");
5078        let opts = VerifyOptions {
5079            key: None,
5080            identity: None,
5081            issuer: Some("https://issuer.example.com"),
5082        };
5083        apply_verify_args(&mut cmd, &opts);
5084        // Again, just verify no panic; the defaults for identity are ".*"
5085    }
5086}