//! OCI image-layout + legacy `docker save` manifest parsing: archive
//! extraction, blob-path resolution, per-arch manifest-descriptor
//! selection, and layer/config enumeration. Split out of the bake
//! monolith; shared helpers/structs reached via `use super::*`.

use super::*;

/// In-process extraction of a well-formed tar archive into `dest`, confined to
/// `dest` (the `tar` crate's `unpack` rejects `..`/absolute escapes and refuses
/// to extract *through* a symlink by default). No subprocess. Used for the
/// `docker save` / `container image save` archive and the `oci-archive:` path —
/// all locally-produced, so a hard error on a malformed entry is appropriate
/// (unlike untrusted registry layers, which `extract_layer_tar` extracts
/// tolerantly per-entry).
pub(super) fn extract_tar_archive(archive: &Path, dest: &Path) -> Result<(), String> {
    let file = std::fs::File::open(archive)
        .map_err(|e| format!("open archive {}: {e}", archive.display()))?;
    let mut ar = tar::Archive::new(file);
    ar.set_preserve_permissions(true);
    ar.set_overwrite(true);
    ar.unpack(dest)
        .map_err(|e| format!("extract archive {}: {e}", archive.display()))?;
    Ok(())
}

pub(super) fn extract_oci_archive(archive: &Path, work_dir: &Path) -> Result<PathBuf, String> {
    let layout = work_dir.join("_oci");
    let _ = std::fs::remove_dir_all(&layout);
    std::fs::create_dir_all(&layout)
        .map_err(|e| format!("create OCI archive extract dir {}: {e}", layout.display()))?;
    extract_tar_archive(archive, &layout)?;
    if !layout.join("index.json").is_file() {
        return Err(format!(
            "OCI archive {} did not contain index.json at archive root",
            archive.display()
        ));
    }
    Ok(layout)
}

/// Extract one OCI layer blob (a tar archive) into `dest`.
///
/// Security: we use the system `tar`, which on both bsdtar (macOS) and
/// GNU tar (Linux) confines extraction to `-C <dest>` by default — `..`
/// components are rejected, a leading `/` is stripped (the entry lands
/// inside `dest`), and extraction *through* a symlink is refused. So a
/// crafted/malicious layer cannot tar-slip out of `dest` onto the host
/// filesystem. We deliberately never pass `-P`, which would DISABLE
/// those protections. The non-zero exit a rejected entry produces is
/// intentionally tolerated: the safe entries still extract and the bad
/// one is skipped, matching the previous inline behaviour. The
/// `tar_extract_confinement` test locks this guarantee against a future
/// regression (e.g. adding `-P`, or switching to an unconfined unpack).
/// Default per-layer decompressed-size cap. A few-KB gzip/zstd layer can expand
/// to TBs (decompression bomb) and fill the host disk. 16 GiB is generous for
/// real images; override with `SUPERMACHINE_MAX_LAYER_BYTES`.
const DEFAULT_MAX_LAYER_BYTES: u64 = 16 * 1024 * 1024 * 1024;

fn layer_decompression_cap() -> u64 {
    std::env::var("SUPERMACHINE_MAX_LAYER_BYTES")
        .ok()
        .and_then(|v| v.parse().ok())
        .unwrap_or(DEFAULT_MAX_LAYER_BYTES)
}

/// A reader that errors once cumulative bytes exceed `limit`. `read_total` is
/// readable afterward to distinguish a cap trip from an unrelated I/O error.
struct LimitedReader<R> {
    inner: R,
    limit: u64,
    read_total: u64,
}
impl<R: std::io::Read> std::io::Read for LimitedReader<R> {
    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
        let n = self.inner.read(buf)?;
        self.read_total += n as u64;
        if self.read_total > self.limit {
            return Err(std::io::Error::new(
                std::io::ErrorKind::Other,
                "decompressed layer exceeds cap",
            ));
        }
        Ok(n)
    }
}

pub(super) fn extract_layer_tar(blob: &Path, dest: &Path) -> Result<(), String> {
    use std::io::Read as _;
    use std::os::unix::ffi::OsStrExt as _;
    let cap = layer_decompression_cap();
    fn set_extracted_owner(path: &Path, uid: u64, gid: u64, follow: bool) {
        let Ok(c) = std::ffi::CString::new(path.as_os_str().as_bytes()) else {
            return;
        };
        unsafe {
            if follow {
                libc::chown(c.as_ptr(), uid as libc::uid_t, gid as libc::gid_t);
            } else {
                libc::lchown(c.as_ptr(), uid as libc::uid_t, gid as libc::gid_t);
            }
        }
    }
    let mut file =
        std::fs::File::open(blob).map_err(|e| format!("open layer {}: {e}", blob.display()))?;
    // Detect compression by magic (system tar auto-detects; we decode
    // in-process so the decompressed stream can be SIZE-CAPPED).
    let mut magic = [0u8; 4];
    let mut got = 0usize;
    while got < 4 {
        match file.read(&mut magic[got..]) {
            Ok(0) => break,
            Ok(n) => got += n,
            Err(e) => return Err(format!("read layer magic: {e}")),
        }
    }
    let head = magic[..got].to_vec();
    let is_gzip = head.starts_with(&[0x1f, 0x8b]);
    let is_zstd = head.starts_with(&[0x28, 0xb5, 0x2f, 0xfd]);
    let rest = std::io::Cursor::new(head).chain(file);
    let decoded: Box<dyn std::io::Read> = if is_gzip {
        Box::new(flate2::read::GzDecoder::new(rest))
    } else if is_zstd {
        Box::new(
            ruzstd::StreamingDecoder::new(rest)
                .map_err(|e| format!("zstd layer decode init: {e}"))?,
        )
    } else {
        Box::new(rest) // plain tar
    };
    let mut limited = LimitedReader {
        inner: decoded,
        limit: cap,
        read_total: 0,
    };

    // In-process untar — no subprocess. Each entry's `unpack_in(dest)` confines
    // extraction to `dest` (rejects `..` / absolute paths, refuses writing
    // through a symlink). We iterate manually and SKIP unsafe/failed entries
    // (rather than aborting the whole layer), matching the system tar's
    // tolerance — the safe entries still extract, the hostile ones never land.
    // Locked by `tar_extract_confinement`. Streaming: decompress → untar as
    // bytes flow, no temp file.
    {
        let mut archive = tar::Archive::new(&mut limited);
        archive.set_preserve_permissions(true);
        archive.set_preserve_mtime(false);
        archive.set_overwrite(true);
        if let Ok(entries) = archive.entries() {
            for entry in entries {
                let mut entry = match entry {
                    Ok(e) => e,
                    // A read error ends the stream (bomb cap trip, or a
                    // malformed tar); we stop and keep what extracted safely.
                    Err(_) => break,
                };
                let entry_path = entry.path().ok().map(|p| p.to_string_lossy().into_owned());
                let uid = entry.header().uid().unwrap_or(0);
                let gid = entry.header().gid().unwrap_or(0);
                let follow = !entry.header().entry_type().is_symlink();
                // Ok(false) = a confined-but-rejected entry (skipped); Err =
                // a problem entry (e.g. symlink-escape) — skip it either way.
                if entry.unpack_in(dest).ok() == Some(true) {
                    if let Some(path) = entry_path {
                        let normalized = path
                            .trim_start_matches("./")
                            .trim_start_matches('/')
                            .trim_end_matches('/');
                        if !normalized.is_empty() {
                            if let Ok(host_path) = confined_join(dest, normalized) {
                                set_extracted_owner(&host_path, uid, gid, follow);
                            }
                        }
                    }
                }
            }
        }
    }
    if limited.read_total > cap {
        return Err(format!(
            "OCI layer decompresses past the {cap}-byte cap (possible decompression \
             bomb); raise SUPERMACHINE_MAX_LAYER_BYTES if intended"
        ));
    }
    Ok(())
}

pub(super) fn blob_path(layout: &Path, digest: &str) -> Result<PathBuf, String> {
    Ok(layout
        .join("blobs/sha256")
        .join(sha256_path_component(digest)?))
}

pub(super) fn read_oci_json_blob(
    layout: &Path,
    digest: &str,
    label: &str,
) -> Result<serde_json::Value, String> {
    let path = blob_path(layout, digest)?;
    let text = std::fs::read_to_string(&path)
        .map_err(|e| format!("read OCI {label} {}: {e}", path.display()))?;
    serde_json::from_str(&text).map_err(|e| format!("parse OCI {label} {}: {e}", path.display()))
}

pub(super) fn descriptor_platform_arch(desc: &serde_json::Value) -> Option<&str> {
    desc.get("platform")
        .and_then(|p| p.get("architecture"))
        .and_then(|v| v.as_str())
}

pub(super) fn find_oci_manifest_descriptor(
    layout: &Path,
    index: &serde_json::Value,
    want_arch: &str,
    depth: usize,
) -> Result<serde_json::Value, String> {
    if depth > 4 {
        return Err("nested OCI image index too deep".to_owned());
    }
    let manifests = index
        .get("manifests")
        .and_then(|v| v.as_array())
        .ok_or_else(|| "OCI index missing manifests".to_owned())?;

    for desc in manifests {
        let media_type = desc
            .get("mediaType")
            .and_then(|v| v.as_str())
            .unwrap_or_default();
        let is_nested_index =
            media_type.contains("image.index") || media_type.contains("manifest.list");
        let arch = descriptor_platform_arch(desc);
        // Recurse into nested indices either when:
        //   - the descriptor matches `want_arch`, OR
        //   - the descriptor has NO platform field (Docker's
        //     `docker save` output puts a single top-level
        //     image.index descriptor without a platform; the
        //     matching manifest lives inside it).
        if is_nested_index && (arch == Some(want_arch) || arch.is_none()) {
            let digest = desc
                .get("digest")
                .and_then(|v| v.as_str())
                .ok_or_else(|| "nested OCI index descriptor missing digest".to_owned())?;
            let nested = read_oci_json_blob(layout, digest, "nested index")?;
            // Best-effort: search the nested index. If it doesn't
            // contain `want_arch`, fall through and try the next sibling.
            if let Ok(found) = find_oci_manifest_descriptor(layout, &nested, want_arch, depth + 1) {
                return Ok(found);
            }
            continue;
        }
        if arch == Some(want_arch) {
            return Ok(desc.clone());
        }
    }
    Err(format!(
        "OCI layout has no linux/{want_arch} image manifest"
    ))
}

pub(super) fn inspect_oci_layout(
    image: &str,
    layout: &Path,
    want_arch: &str,
) -> Result<serde_json::Value, String> {
    // Try OCI path first; fall back to legacy Docker manifest.json
    // on missing-blob failures (Docker Desktop's `docker save` of
    // multi-arch images writes the outer OCI image-index but
    // omits the per-arch manifest BLOB itself, even though it
    // includes the per-arch layers — only the LEGACY manifest.json
    // at the tar root flattens the reference for us). Apple's
    // `container image save` and registry pulls work via the OCI
    // path; legacy fallback handles the Docker Desktop case.
    match inspect_oci_layout_via_index(image, layout, want_arch) {
        Ok(v) => Ok(v),
        Err(e) => {
            // Surface OCI errors that aren't "missing blob" without
            // attempting the legacy fallback; those indicate a
            // genuine OCI structure problem, not a Docker quirk.
            if !e.contains("No such file or directory") && !e.contains("read OCI manifest") {
                return Err(e);
            }
            // Try the legacy Docker manifest.json fallback. If THAT
            // also fails, prefer the original OCI error since it's
            // usually more diagnostic.
            inspect_legacy_docker_manifest(image, layout, want_arch).map_err(|legacy_err| {
                format!(
                    "OCI inspect failed ({e}); legacy manifest.json \
                     fallback also failed: {legacy_err}"
                )
            })
        }
    }
}

pub(super) fn inspect_oci_layout_via_index(
    image: &str,
    layout: &Path,
    want_arch: &str,
) -> Result<serde_json::Value, String> {
    let index_text = std::fs::read_to_string(layout.join("index.json")).map_err(|e| {
        format!(
            "read OCI layout index {}: {e}",
            layout.join("index.json").display()
        )
    })?;
    let index: serde_json::Value =
        serde_json::from_str(&index_text).map_err(|e| format!("parse OCI layout index: {e}"))?;
    let manifest_desc = find_oci_manifest_descriptor(layout, &index, want_arch, 0)?;
    let manifest_digest = manifest_desc
        .get("digest")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "OCI manifest descriptor missing digest".to_owned())?;
    let manifest = read_oci_json_blob(layout, manifest_digest, "manifest")?;
    let config_digest = manifest
        .get("config")
        .and_then(|v| v.get("digest"))
        .and_then(|v| v.as_str())
        .ok_or_else(|| "OCI manifest missing config digest".to_owned())?;
    let config = read_oci_json_blob(layout, config_digest, "config")?;
    let cfg = config
        .get("config")
        .cloned()
        .unwrap_or_else(|| serde_json::json!({}));
    let arch = config
        .get("architecture")
        .and_then(|v| v.as_str())
        .or_else(|| descriptor_platform_arch(&manifest_desc))
        .unwrap_or(want_arch);
    Ok(serde_json::json!({
        "Id": format!("sha256:{}", strip_sha256(config_digest)),
        "Architecture": arch,
        "RepoTags": [image],
        "Config": {
            "Env": cfg.get("Env").cloned().unwrap_or(serde_json::Value::Null),
            "Entrypoint": cfg.get("Entrypoint").cloned().unwrap_or(serde_json::Value::Null),
            "Cmd": cfg.get("Cmd").cloned().unwrap_or(serde_json::Value::Null),
            "WorkingDir": cfg.get("WorkingDir").cloned().unwrap_or(serde_json::Value::Null),
            "User": cfg.get("User").cloned().unwrap_or(serde_json::Value::Null),
        }
    }))
}

/// Read the legacy Docker `manifest.json` at the tar root and pick
/// the entry matching `image`. Used as a fallback for Docker
/// Desktop's `docker save` of multi-arch images, which writes an
/// outer OCI image-index without the per-arch manifest blobs but
/// always includes a flattened `manifest.json` referencing the
/// actual layers + config.
///
/// Format (per Docker spec):
/// ```json
/// [ { "Config": "blobs/sha256/<digest>", "RepoTags": ["..."],
///     "Layers": ["blobs/sha256/<digest>", ...] } ]
/// ```
pub(super) fn read_legacy_docker_manifest_entry(
    image: &str,
    layout: &Path,
) -> Result<serde_json::Value, String> {
    let path = layout.join("manifest.json");
    let text = std::fs::read_to_string(&path)
        .map_err(|e| format!("read legacy manifest.json {}: {e}", path.display()))?;
    let entries: Vec<serde_json::Value> =
        serde_json::from_str(&text).map_err(|e| format!("parse legacy manifest.json: {e}"))?;
    if entries.is_empty() {
        return Err("legacy manifest.json has no entries".to_owned());
    }
    // Try to match by RepoTags first (when the tar contains multiple
    // image refs, this is the only way to disambiguate). Fall back
    // to entry[0] when nothing matches — `docker save IMAGE` usually
    // writes exactly one entry whose tag may differ from the
    // user-supplied ref (canonical form, registry prefix, etc.).
    let matched = entries.iter().find(|e| {
        e.get("RepoTags")
            .and_then(|v| v.as_array())
            .map(|tags| tags.iter().any(|t| t.as_str() == Some(image)))
            .unwrap_or(false)
    });
    Ok(matched.cloned().unwrap_or_else(|| entries[0].clone()))
}

pub(super) fn inspect_legacy_docker_manifest(
    image: &str,
    layout: &Path,
    want_arch: &str,
) -> Result<serde_json::Value, String> {
    let entry = read_legacy_docker_manifest_entry(image, layout)?;
    let config_rel = entry
        .get("Config")
        .and_then(|v| v.as_str())
        .ok_or_else(|| "legacy manifest entry missing Config".to_owned())?;
    let config_path = layout.join(config_rel);
    let config_text = std::fs::read_to_string(&config_path)
        .map_err(|e| format!("read legacy config {}: {e}", config_path.display()))?;
    let config: serde_json::Value =
        serde_json::from_str(&config_text).map_err(|e| format!("parse legacy config: {e}"))?;
    // Verify the saved image's arch matches what the caller asked
    // for. `docker save --platform X` only includes layers for X,
    // so this should always agree — but we'd rather a clear error
    // than silently boot wrong-arch binaries.
    let actual_arch = config
        .get("architecture")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    if actual_arch != want_arch {
        return Err(format!(
            "legacy manifest.json describes a linux/{actual_arch} image but \
             the request asked for linux/{want_arch}; re-run `docker save` \
             with `--platform linux/{want_arch}` against a multi-arch source"
        ));
    }
    let cfg = config
        .get("config")
        .cloned()
        .unwrap_or_else(|| serde_json::json!({}));
    // Derive an Id from the config sha so callers' cache keys stay
    // stable across the OCI vs legacy paths.
    let config_digest = config_rel
        .strip_prefix("blobs/sha256/")
        .unwrap_or(config_rel);
    Ok(serde_json::json!({
        "Id": format!("sha256:{}", config_digest),
        "Architecture": actual_arch,
        "RepoTags": [image],
        "Config": {
            "Env": cfg.get("Env").cloned().unwrap_or(serde_json::Value::Null),
            "Entrypoint": cfg.get("Entrypoint").cloned().unwrap_or(serde_json::Value::Null),
            "Cmd": cfg.get("Cmd").cloned().unwrap_or(serde_json::Value::Null),
            "WorkingDir": cfg.get("WorkingDir").cloned().unwrap_or(serde_json::Value::Null),
            "User": cfg.get("User").cloned().unwrap_or(serde_json::Value::Null),
        }
    }))
}

#[cfg(test)]
mod descriptor_tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn descriptor_platform_arch_reads_nested_field() {
        let d = json!({"platform": {"architecture": "amd64", "os": "linux"}});
        assert_eq!(descriptor_platform_arch(&d), Some("amd64"));
        // No platform → None (used to drive the no-platform passthrough path).
        assert_eq!(descriptor_platform_arch(&json!({"digest": "x"})), None);
        assert_eq!(descriptor_platform_arch(&json!({"platform": {}})), None);
    }

    #[test]
    fn find_manifest_picks_the_requested_arch_from_a_flat_index() {
        // A flat index (no nested image.index) never touches the filesystem,
        // so `layout` is irrelevant here.
        let index = json!({"manifests": [
            {"mediaType": "application/vnd.oci.image.manifest.v1+json",
             "digest": "sha256:aaa", "platform": {"architecture": "arm64", "os": "linux"}},
            {"mediaType": "application/vnd.oci.image.manifest.v1+json",
             "digest": "sha256:bbb", "platform": {"architecture": "amd64", "os": "linux"}},
        ]});
        let got = find_oci_manifest_descriptor(Path::new("/unused"), &index, "amd64", 0)
            .expect("amd64 present");
        assert_eq!(
            got.get("digest").and_then(|v| v.as_str()),
            Some("sha256:bbb")
        );

        let got = find_oci_manifest_descriptor(Path::new("/unused"), &index, "arm64", 0)
            .expect("arm64 present");
        assert_eq!(
            got.get("digest").and_then(|v| v.as_str()),
            Some("sha256:aaa")
        );
    }

    #[test]
    fn find_manifest_errors_when_arch_absent() {
        let index = json!({"manifests": [
            {"mediaType": "application/vnd.oci.image.manifest.v1+json",
             "digest": "sha256:aaa", "platform": {"architecture": "arm64", "os": "linux"}},
        ]});
        let err =
            find_oci_manifest_descriptor(Path::new("/unused"), &index, "amd64", 0).unwrap_err();
        assert!(err.contains("amd64"), "got: {err}");
    }

    #[test]
    fn find_manifest_rejects_missing_manifests_and_excessive_nesting() {
        assert!(find_oci_manifest_descriptor(Path::new("/x"), &json!({}), "amd64", 0).is_err());
        // depth guard: > 4 bails out before any file read.
        assert!(find_oci_manifest_descriptor(
            Path::new("/x"),
            &json!({"manifests": []}),
            "amd64",
            5
        )
        .is_err());
    }

    #[test]
    fn blob_path_rejects_traversal_digests() {
        let layout = Path::new("/layout");
        // Clean digest → blobs/sha256/<hex>.
        let hex = "a".repeat(64);
        let p = blob_path(layout, &format!("sha256:{hex}")).unwrap();
        assert!(p.ends_with(format!("blobs/sha256/{hex}")));
        // Traversal attempts via a crafted digest are rejected.
        assert!(blob_path(layout, "sha256:../../etc/passwd").is_err());
        assert!(blob_path(layout, "sha256:ab/cd").is_err());
        assert!(blob_path(layout, "sha256:").is_err());
    }
}
