diff --git a/crates/karapace-runtime/Cargo.toml b/crates/karapace-runtime/Cargo.toml new file mode 100644 index 0000000..88ad24c --- /dev/null +++ b/crates/karapace-runtime/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "karapace-runtime" +description = "Container runtime backends, image management, sandbox, and host integration for Karapace" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +blake3.workspace = true +libc.workspace = true +tracing.workspace = true +tempfile.workspace = true +karapace-schema = { path = "../karapace-schema" } +karapace-store = { path = "../karapace-store" } diff --git a/crates/karapace-runtime/karapace-runtime.cdx.json b/crates/karapace-runtime/karapace-runtime.cdx.json new file mode 100644 index 0000000..d519dce --- /dev/null +++ b/crates/karapace-runtime/karapace-runtime.cdx.json @@ -0,0 +1,1669 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.3", + "version": 1, + "serialNumber": "urn:uuid:6af51e2d-6054-4246-89e9-a284bd831639", + "metadata": { + "timestamp": "2026-02-22T14:03:10.557085258Z", + "tools": [ + { + "vendor": "CycloneDX", + "name": "cargo-cyclonedx", + "version": "0.5.5" + } + ], + "component": { + "type": "library", + "bom-ref": "path+file:///home/lateuf/Projects/Karapace/crates/karapace-runtime#0.1.0", + "name": "karapace-runtime", + "version": "0.1.0", + "description": "Container runtime backends, image management, sandbox, and host integration for Karapace", + "scope": "required", + "licenses": [ + { + "expression": "EUPL-1.2" + } + ], + "purl": "pkg:cargo/karapace-runtime@0.1.0?download_url=file://.", + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/marcoallegretti/karapace" + } + ], + "components": [ + { + "type": "library", + "bom-ref": "path+file:///home/lateuf/Projects/Karapace/crates/karapace-runtime#0.1.0 bin-target-0", + "name": "karapace_runtime", + "version": "0.1.0", + "purl": "pkg:cargo/karapace-runtime@0.1.0?download_url=file://.#src/lib.rs" + } + ] + } + }, + "components": [ + { + "type": "library", + "bom-ref": "path+file:///home/lateuf/Projects/Karapace/crates/karapace-schema#0.1.0", + "name": "karapace-schema", + "version": "0.1.0", + "description": "Manifest parsing, normalization, identity hashing, and lock file for Karapace", + "scope": "required", + "licenses": [ + { + "expression": "EUPL-1.2" + } + ], + "purl": "pkg:cargo/karapace-schema@0.1.0?download_url=file://../karapace-schema", + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/marcoallegretti/karapace" + } + ] + }, + { + "type": "library", + "bom-ref": "path+file:///home/lateuf/Projects/Karapace/crates/karapace-store#0.1.0", + "name": "karapace-store", + "version": "0.1.0", + "description": "Content-addressable store, metadata, layers, GC, and integrity for Karapace", + "scope": "required", + "licenses": [ + { + "expression": "EUPL-1.2" + } + ], + "purl": "pkg:cargo/karapace-store@0.1.0?download_url=file://../karapace-store", + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/marcoallegretti/karapace" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#arrayref@0.3.9", + "name": "arrayref", + "version": "0.3.9", + "description": "Macros to take array references of slices", + "scope": "required", + "licenses": [ + { + "expression": "BSD-2-Clause" + } + ], + "purl": "pkg:cargo/arrayref@0.3.9", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/arrayref" + }, + { + "type": "vcs", + "url": "https://github.com/droundy/arrayref" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#arrayvec@0.7.6", + "name": "arrayvec", + "version": "0.7.6", + "description": "A vector with fixed capacity, backed by an array (it can be stored on the stack too). Implements fixed capacity ArrayVec and ArrayString.", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/arrayvec@0.7.6", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/arrayvec/" + }, + { + "type": "vcs", + "url": "https://github.com/bluss/arrayvec" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#autocfg@1.5.0", + "name": "autocfg", + "version": "1.5.0", + "description": "Automatic cfg for Rust compiler features", + "scope": "excluded", + "licenses": [ + { + "expression": "Apache-2.0 OR MIT" + } + ], + "purl": "pkg:cargo/autocfg@1.5.0", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/autocfg/" + }, + { + "type": "vcs", + "url": "https://github.com/cuviper/autocfg" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#bitflags@2.11.0", + "name": "bitflags", + "version": "2.11.0", + "description": "A macro to generate structures which behave like bitflags. ", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/bitflags@2.11.0", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/bitflags" + }, + { + "type": "website", + "url": "https://github.com/bitflags/bitflags" + }, + { + "type": "vcs", + "url": "https://github.com/bitflags/bitflags" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#blake3@1.8.3", + "name": "blake3", + "version": "1.8.3", + "description": "the BLAKE3 hash function", + "scope": "required", + "licenses": [ + { + "expression": "CC0-1.0 OR Apache-2.0 OR Apache-2.0 WITH LLVM-exception" + } + ], + "purl": "pkg:cargo/blake3@1.8.3", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/blake3" + }, + { + "type": "vcs", + "url": "https://github.com/BLAKE3-team/BLAKE3" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#cc@1.2.56", + "name": "cc", + "version": "1.2.56", + "description": "A build-time dependency for Cargo build scripts to assist in invoking the native C compiler to compile native C code into a static archive to be linked into Rust code. ", + "scope": "excluded", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/cc@1.2.56", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/cc" + }, + { + "type": "website", + "url": "https://github.com/rust-lang/cc-rs" + }, + { + "type": "vcs", + "url": "https://github.com/rust-lang/cc-rs" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#cfg-if@1.0.4", + "name": "cfg-if", + "version": "1.0.4", + "description": "A macro to ergonomically define an item depending on a large number of #[cfg] parameters. Structured like an if-else chain, the first matching branch is the item that gets emitted. ", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/cfg-if@1.0.4", + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/rust-lang/cfg-if" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#chrono@0.4.43", + "name": "chrono", + "version": "0.4.43", + "description": "Date and time library for Rust", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/chrono@0.4.43", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/chrono/" + }, + { + "type": "website", + "url": "https://github.com/chronotope/chrono" + }, + { + "type": "vcs", + "url": "https://github.com/chronotope/chrono" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#constant_time_eq@0.4.2", + "name": "constant_time_eq", + "version": "0.4.2", + "description": "Compares two equal-sized byte strings in constant time.", + "scope": "required", + "licenses": [ + { + "expression": "CC0-1.0 OR MIT-0 OR Apache-2.0" + } + ], + "purl": "pkg:cargo/constant_time_eq@0.4.2", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/constant_time_eq" + }, + { + "type": "vcs", + "url": "https://github.com/cesarb/constant_time_eq" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#cpufeatures@0.2.17", + "name": "cpufeatures", + "version": "0.2.17", + "description": "Lightweight runtime CPU feature detection for aarch64, loongarch64, and x86/x86_64 targets, with no_std support and support for mobile targets including Android and iOS ", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/cpufeatures@0.2.17", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/cpufeatures" + }, + { + "type": "vcs", + "url": "https://github.com/RustCrypto/utils" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#equivalent@1.0.2", + "name": "equivalent", + "version": "1.0.2", + "description": "Traits for key comparison in maps.", + "scope": "required", + "licenses": [ + { + "expression": "Apache-2.0 OR MIT" + } + ], + "purl": "pkg:cargo/equivalent@1.0.2", + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/indexmap-rs/equivalent" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#errno@0.3.14", + "name": "errno", + "version": "0.3.14", + "description": "Cross-platform interface to the `errno` variable.", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/errno@0.3.14", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/errno" + }, + { + "type": "vcs", + "url": "https://github.com/lambda-fairy/rust-errno" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#fastrand@2.3.0", + "name": "fastrand", + "version": "2.3.0", + "description": "A simple and fast random number generator", + "scope": "required", + "licenses": [ + { + "expression": "Apache-2.0 OR MIT" + } + ], + "purl": "pkg:cargo/fastrand@2.3.0", + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/smol-rs/fastrand" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#filetime@0.2.27", + "name": "filetime", + "version": "0.2.27", + "description": "Platform-agnostic accessors of timestamps in File metadata ", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/filetime@0.2.27", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/filetime" + }, + { + "type": "website", + "url": "https://github.com/alexcrichton/filetime" + }, + { + "type": "vcs", + "url": "https://github.com/alexcrichton/filetime" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#find-msvc-tools@0.1.9", + "name": "find-msvc-tools", + "version": "0.1.9", + "description": "Find windows-specific tools, read MSVC versions from the registry and from COM interfaces", + "scope": "excluded", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/find-msvc-tools@0.1.9", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/find-msvc-tools" + }, + { + "type": "vcs", + "url": "https://github.com/rust-lang/cc-rs" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#fs2@0.4.3", + "name": "fs2", + "version": "0.4.3", + "description": "Cross-platform file locks and file duplication.", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/fs2@0.4.3", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/fs2" + }, + { + "type": "vcs", + "url": "https://github.com/danburkert/fs2-rs" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#getrandom@0.4.1", + "name": "getrandom", + "version": "0.4.1", + "description": "A small cross-platform library for retrieving random data from system source", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/getrandom@0.4.1", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/getrandom" + }, + { + "type": "vcs", + "url": "https://github.com/rust-random/getrandom" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#hashbrown@0.16.1", + "name": "hashbrown", + "version": "0.16.1", + "description": "A Rust port of Google's SwissTable hash map", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/hashbrown@0.16.1", + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/rust-lang/hashbrown" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#iana-time-zone@0.1.65", + "name": "iana-time-zone", + "version": "0.1.65", + "description": "get the IANA time zone for the current system", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/iana-time-zone@0.1.65", + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/strawlab/iana-time-zone" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#indexmap@2.13.0", + "name": "indexmap", + "version": "2.13.0", + "description": "A hash table with consistent order and fast iteration.", + "scope": "required", + "licenses": [ + { + "expression": "Apache-2.0 OR MIT" + } + ], + "purl": "pkg:cargo/indexmap@2.13.0", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/indexmap/" + }, + { + "type": "vcs", + "url": "https://github.com/indexmap-rs/indexmap" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#itoa@1.0.17", + "name": "itoa", + "version": "1.0.17", + "description": "Fast integer primitive to string conversion", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/itoa@1.0.17", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/itoa" + }, + { + "type": "vcs", + "url": "https://github.com/dtolnay/itoa" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#libc@0.2.180", + "name": "libc", + "version": "0.2.180", + "description": "Raw FFI bindings to platform libraries like libc.", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/libc@0.2.180", + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/rust-lang/libc" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#linux-raw-sys@0.11.0", + "name": "linux-raw-sys", + "version": "0.11.0", + "description": "Generated bindings for Linux's userspace API", + "scope": "required", + "licenses": [ + { + "expression": "Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT" + } + ], + "purl": "pkg:cargo/linux-raw-sys@0.11.0", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/linux-raw-sys" + }, + { + "type": "vcs", + "url": "https://github.com/sunfishcode/linux-raw-sys" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#memchr@2.8.0", + "name": "memchr", + "version": "2.8.0", + "description": "Provides extremely fast (uses SIMD on x86_64, aarch64 and wasm32) routines for 1, 2 or 3 byte search and single substring search. ", + "scope": "required", + "licenses": [ + { + "expression": "Unlicense OR MIT" + } + ], + "purl": "pkg:cargo/memchr@2.8.0", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/memchr/" + }, + { + "type": "website", + "url": "https://github.com/BurntSushi/memchr" + }, + { + "type": "vcs", + "url": "https://github.com/BurntSushi/memchr" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#num-traits@0.2.19", + "name": "num-traits", + "version": "0.2.19", + "description": "Numeric traits for generic mathematics", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/num-traits@0.2.19", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/num-traits" + }, + { + "type": "website", + "url": "https://github.com/rust-num/num-traits" + }, + { + "type": "vcs", + "url": "https://github.com/rust-num/num-traits" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#once_cell@1.21.3", + "name": "once_cell", + "version": "1.21.3", + "description": "Single assignment cells and lazy values.", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/once_cell@1.21.3", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/once_cell" + }, + { + "type": "vcs", + "url": "https://github.com/matklad/once_cell" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#pin-project-lite@0.2.16", + "name": "pin-project-lite", + "version": "0.2.16", + "description": "A lightweight version of pin-project written with declarative macros. ", + "scope": "required", + "licenses": [ + { + "expression": "Apache-2.0 OR MIT" + } + ], + "purl": "pkg:cargo/pin-project-lite@0.2.16", + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/taiki-e/pin-project-lite" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#proc-macro2@1.0.106", + "name": "proc-macro2", + "version": "1.0.106", + "description": "A substitute implementation of the compiler's `proc_macro` API to decouple token-based libraries from the procedural macro use case.", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/proc-macro2@1.0.106", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/proc-macro2" + }, + { + "type": "vcs", + "url": "https://github.com/dtolnay/proc-macro2" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#quote@1.0.44", + "name": "quote", + "version": "1.0.44", + "description": "Quasi-quoting macro quote!(...)", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/quote@1.0.44", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/quote/" + }, + { + "type": "vcs", + "url": "https://github.com/dtolnay/quote" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#rustix@1.1.3", + "name": "rustix", + "version": "1.1.3", + "description": "Safe Rust bindings to POSIX/Unix/Linux/Winsock-like syscalls", + "scope": "required", + "licenses": [ + { + "expression": "Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT" + } + ], + "purl": "pkg:cargo/rustix@1.1.3", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/rustix" + }, + { + "type": "vcs", + "url": "https://github.com/bytecodealliance/rustix" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#serde@1.0.228", + "name": "serde", + "version": "1.0.228", + "description": "A generic serialization/deserialization framework", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/serde@1.0.228", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/serde" + }, + { + "type": "website", + "url": "https://serde.rs" + }, + { + "type": "vcs", + "url": "https://github.com/serde-rs/serde" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#serde_core@1.0.228", + "name": "serde_core", + "version": "1.0.228", + "description": "Serde traits only, with no support for derive -- use the `serde` crate instead", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/serde_core@1.0.228", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/serde_core" + }, + { + "type": "website", + "url": "https://serde.rs" + }, + { + "type": "vcs", + "url": "https://github.com/serde-rs/serde" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#serde_derive@1.0.228", + "name": "serde_derive", + "version": "1.0.228", + "description": "Macros 1.1 implementation of #[derive(Serialize, Deserialize)]", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/serde_derive@1.0.228", + "externalReferences": [ + { + "type": "documentation", + "url": "https://serde.rs/derive.html" + }, + { + "type": "website", + "url": "https://serde.rs" + }, + { + "type": "vcs", + "url": "https://github.com/serde-rs/serde" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#serde_json@1.0.149", + "name": "serde_json", + "version": "1.0.149", + "description": "A JSON serialization file format", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/serde_json@1.0.149", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/serde_json" + }, + { + "type": "vcs", + "url": "https://github.com/serde-rs/json" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#serde_spanned@0.6.9", + "name": "serde_spanned", + "version": "0.6.9", + "description": "Serde-compatible spanned Value", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/serde_spanned@0.6.9", + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/toml-rs/toml" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#shlex@1.3.0", + "name": "shlex", + "version": "1.3.0", + "description": "Split a string into shell words, like Python's shlex.", + "scope": "excluded", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/shlex@1.3.0", + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/comex/rust-shlex" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#syn@2.0.117", + "name": "syn", + "version": "2.0.117", + "description": "Parser for Rust source code", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/syn@2.0.117", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/syn" + }, + { + "type": "vcs", + "url": "https://github.com/dtolnay/syn" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#tar@0.4.44", + "name": "tar", + "version": "0.4.44", + "description": "A Rust implementation of a TAR file reader and writer. This library does not currently handle compression, but it is abstract over all I/O readers and writers. Additionally, great lengths are taken to ensure that the entire contents are never required to be entirely resident in memory all at once. ", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/tar@0.4.44", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/tar" + }, + { + "type": "website", + "url": "https://github.com/alexcrichton/tar-rs" + }, + { + "type": "vcs", + "url": "https://github.com/alexcrichton/tar-rs" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#tempfile@3.25.0", + "name": "tempfile", + "version": "3.25.0", + "description": "A library for managing temporary files and directories.", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/tempfile@3.25.0", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/tempfile" + }, + { + "type": "website", + "url": "https://stebalien.com/projects/tempfile-rs/" + }, + { + "type": "vcs", + "url": "https://github.com/Stebalien/tempfile" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#thiserror-impl@2.0.18", + "name": "thiserror-impl", + "version": "2.0.18", + "description": "Implementation detail of the `thiserror` crate", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/thiserror-impl@2.0.18", + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/dtolnay/thiserror" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#thiserror@2.0.18", + "name": "thiserror", + "version": "2.0.18", + "description": "derive(Error)", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/thiserror@2.0.18", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/thiserror" + }, + { + "type": "vcs", + "url": "https://github.com/dtolnay/thiserror" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#toml@0.8.23", + "name": "toml", + "version": "0.8.23", + "description": "A native Rust encoder and decoder of TOML-formatted files and streams. Provides implementations of the standard Serialize/Deserialize traits for TOML data to facilitate deserializing and serializing Rust structures. ", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/toml@0.8.23", + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/toml-rs/toml" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#toml_datetime@0.6.11", + "name": "toml_datetime", + "version": "0.6.11", + "description": "A TOML-compatible datetime type", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/toml_datetime@0.6.11", + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/toml-rs/toml" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#toml_edit@0.22.27", + "name": "toml_edit", + "version": "0.22.27", + "description": "Yet another format-preserving TOML parser.", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/toml_edit@0.22.27", + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/toml-rs/toml" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#toml_write@0.1.2", + "name": "toml_write", + "version": "0.1.2", + "description": "A low-level interface for writing out TOML ", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/toml_write@0.1.2", + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/toml-rs/toml" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#tracing-attributes@0.1.31", + "name": "tracing-attributes", + "version": "0.1.31", + "description": "Procedural macro attributes for automatically instrumenting functions. ", + "scope": "required", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:cargo/tracing-attributes@0.1.31", + "externalReferences": [ + { + "type": "website", + "url": "https://tokio.rs" + }, + { + "type": "vcs", + "url": "https://github.com/tokio-rs/tracing" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#tracing-core@0.1.36", + "name": "tracing-core", + "version": "0.1.36", + "description": "Core primitives for application-level tracing. ", + "scope": "required", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:cargo/tracing-core@0.1.36", + "externalReferences": [ + { + "type": "website", + "url": "https://tokio.rs" + }, + { + "type": "vcs", + "url": "https://github.com/tokio-rs/tracing" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#tracing@0.1.44", + "name": "tracing", + "version": "0.1.44", + "description": "Application-level tracing for Rust. ", + "scope": "required", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:cargo/tracing@0.1.44", + "externalReferences": [ + { + "type": "website", + "url": "https://tokio.rs" + }, + { + "type": "vcs", + "url": "https://github.com/tokio-rs/tracing" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#unicode-ident@1.0.24", + "name": "unicode-ident", + "version": "1.0.24", + "description": "Determine whether characters have the XID_Start or XID_Continue properties according to Unicode Standard Annex #31", + "scope": "required", + "licenses": [ + { + "expression": "(MIT OR Apache-2.0) AND Unicode-3.0" + } + ], + "purl": "pkg:cargo/unicode-ident@1.0.24", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/unicode-ident" + }, + { + "type": "vcs", + "url": "https://github.com/dtolnay/unicode-ident" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#winnow@0.7.14", + "name": "winnow", + "version": "0.7.14", + "description": "A byte-oriented, zero-copy, parser combinators library", + "scope": "required", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:cargo/winnow@0.7.14", + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/winnow-rs/winnow" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#xattr@1.6.1", + "name": "xattr", + "version": "1.6.1", + "description": "unix extended filesystem attributes", + "scope": "required", + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/xattr@1.6.1", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/xattr" + }, + { + "type": "vcs", + "url": "https://github.com/Stebalien/xattr" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#zmij@1.0.21", + "name": "zmij", + "version": "1.0.21", + "description": "A double-to-string conversion algorithm based on Schubfach and yy", + "scope": "required", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:cargo/zmij@1.0.21", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/zmij" + }, + { + "type": "vcs", + "url": "https://github.com/dtolnay/zmij" + } + ] + } + ], + "dependencies": [ + { + "ref": "path+file:///home/lateuf/Projects/Karapace/crates/karapace-runtime#0.1.0", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#blake3@1.8.3", + "path+file:///home/lateuf/Projects/Karapace/crates/karapace-schema#0.1.0", + "path+file:///home/lateuf/Projects/Karapace/crates/karapace-store#0.1.0", + "registry+https://github.com/rust-lang/crates.io-index#libc@0.2.180", + "registry+https://github.com/rust-lang/crates.io-index#serde@1.0.228", + "registry+https://github.com/rust-lang/crates.io-index#serde_json@1.0.149", + "registry+https://github.com/rust-lang/crates.io-index#tempfile@3.25.0", + "registry+https://github.com/rust-lang/crates.io-index#thiserror@2.0.18", + "registry+https://github.com/rust-lang/crates.io-index#tracing@0.1.44" + ] + }, + { + "ref": "path+file:///home/lateuf/Projects/Karapace/crates/karapace-schema#0.1.0", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#blake3@1.8.3", + "registry+https://github.com/rust-lang/crates.io-index#serde@1.0.228", + "registry+https://github.com/rust-lang/crates.io-index#serde_json@1.0.149", + "registry+https://github.com/rust-lang/crates.io-index#tempfile@3.25.0", + "registry+https://github.com/rust-lang/crates.io-index#thiserror@2.0.18", + "registry+https://github.com/rust-lang/crates.io-index#toml@0.8.23" + ] + }, + { + "ref": "path+file:///home/lateuf/Projects/Karapace/crates/karapace-store#0.1.0", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#blake3@1.8.3", + "registry+https://github.com/rust-lang/crates.io-index#chrono@0.4.43", + "registry+https://github.com/rust-lang/crates.io-index#fs2@0.4.3", + "path+file:///home/lateuf/Projects/Karapace/crates/karapace-schema#0.1.0", + "registry+https://github.com/rust-lang/crates.io-index#serde@1.0.228", + "registry+https://github.com/rust-lang/crates.io-index#serde_json@1.0.149", + "registry+https://github.com/rust-lang/crates.io-index#tar@0.4.44", + "registry+https://github.com/rust-lang/crates.io-index#tempfile@3.25.0", + "registry+https://github.com/rust-lang/crates.io-index#thiserror@2.0.18", + "registry+https://github.com/rust-lang/crates.io-index#tracing@0.1.44" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#arrayref@0.3.9", + "dependsOn": [] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#arrayvec@0.7.6", + "dependsOn": [] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#autocfg@1.5.0", + "dependsOn": [] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#bitflags@2.11.0", + "dependsOn": [] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#blake3@1.8.3", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#arrayref@0.3.9", + "registry+https://github.com/rust-lang/crates.io-index#arrayvec@0.7.6", + "registry+https://github.com/rust-lang/crates.io-index#cc@1.2.56", + "registry+https://github.com/rust-lang/crates.io-index#cfg-if@1.0.4", + "registry+https://github.com/rust-lang/crates.io-index#constant_time_eq@0.4.2", + "registry+https://github.com/rust-lang/crates.io-index#cpufeatures@0.2.17" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#cc@1.2.56", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#find-msvc-tools@0.1.9", + "registry+https://github.com/rust-lang/crates.io-index#shlex@1.3.0" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#cfg-if@1.0.4", + "dependsOn": [] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#chrono@0.4.43", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#iana-time-zone@0.1.65", + "registry+https://github.com/rust-lang/crates.io-index#num-traits@0.2.19", + "registry+https://github.com/rust-lang/crates.io-index#serde@1.0.228" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#constant_time_eq@0.4.2", + "dependsOn": [] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#cpufeatures@0.2.17", + "dependsOn": [] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#equivalent@1.0.2", + "dependsOn": [] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#errno@0.3.14", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#libc@0.2.180" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#fastrand@2.3.0", + "dependsOn": [] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#filetime@0.2.27", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#cfg-if@1.0.4", + "registry+https://github.com/rust-lang/crates.io-index#libc@0.2.180" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#find-msvc-tools@0.1.9", + "dependsOn": [] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#fs2@0.4.3", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#libc@0.2.180" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#getrandom@0.4.1", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#cfg-if@1.0.4", + "registry+https://github.com/rust-lang/crates.io-index#libc@0.2.180" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#hashbrown@0.16.1", + "dependsOn": [] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#iana-time-zone@0.1.65", + "dependsOn": [] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#indexmap@2.13.0", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#equivalent@1.0.2", + "registry+https://github.com/rust-lang/crates.io-index#hashbrown@0.16.1", + "registry+https://github.com/rust-lang/crates.io-index#serde_core@1.0.228" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#itoa@1.0.17", + "dependsOn": [] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#libc@0.2.180", + "dependsOn": [] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#linux-raw-sys@0.11.0", + "dependsOn": [] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#memchr@2.8.0", + "dependsOn": [] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#num-traits@0.2.19", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#autocfg@1.5.0" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#once_cell@1.21.3", + "dependsOn": [] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#pin-project-lite@0.2.16", + "dependsOn": [] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#proc-macro2@1.0.106", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#unicode-ident@1.0.24" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#quote@1.0.44", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#proc-macro2@1.0.106" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#rustix@1.1.3", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#bitflags@2.11.0", + "registry+https://github.com/rust-lang/crates.io-index#errno@0.3.14", + "registry+https://github.com/rust-lang/crates.io-index#libc@0.2.180", + "registry+https://github.com/rust-lang/crates.io-index#linux-raw-sys@0.11.0" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#serde@1.0.228", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#serde_core@1.0.228", + "registry+https://github.com/rust-lang/crates.io-index#serde_derive@1.0.228" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#serde_core@1.0.228", + "dependsOn": [] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#serde_derive@1.0.228", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#proc-macro2@1.0.106", + "registry+https://github.com/rust-lang/crates.io-index#quote@1.0.44", + "registry+https://github.com/rust-lang/crates.io-index#syn@2.0.117" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#serde_json@1.0.149", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#itoa@1.0.17", + "registry+https://github.com/rust-lang/crates.io-index#memchr@2.8.0", + "registry+https://github.com/rust-lang/crates.io-index#serde_core@1.0.228", + "registry+https://github.com/rust-lang/crates.io-index#zmij@1.0.21" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#serde_spanned@0.6.9", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#serde@1.0.228" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#shlex@1.3.0", + "dependsOn": [] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#syn@2.0.117", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#proc-macro2@1.0.106", + "registry+https://github.com/rust-lang/crates.io-index#quote@1.0.44", + "registry+https://github.com/rust-lang/crates.io-index#unicode-ident@1.0.24" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#tar@0.4.44", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#filetime@0.2.27", + "registry+https://github.com/rust-lang/crates.io-index#libc@0.2.180", + "registry+https://github.com/rust-lang/crates.io-index#xattr@1.6.1" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#tempfile@3.25.0", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#fastrand@2.3.0", + "registry+https://github.com/rust-lang/crates.io-index#getrandom@0.4.1", + "registry+https://github.com/rust-lang/crates.io-index#once_cell@1.21.3", + "registry+https://github.com/rust-lang/crates.io-index#rustix@1.1.3" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#thiserror-impl@2.0.18", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#proc-macro2@1.0.106", + "registry+https://github.com/rust-lang/crates.io-index#quote@1.0.44", + "registry+https://github.com/rust-lang/crates.io-index#syn@2.0.117" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#thiserror@2.0.18", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#thiserror-impl@2.0.18" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#toml@0.8.23", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#serde@1.0.228", + "registry+https://github.com/rust-lang/crates.io-index#serde_spanned@0.6.9", + "registry+https://github.com/rust-lang/crates.io-index#toml_datetime@0.6.11", + "registry+https://github.com/rust-lang/crates.io-index#toml_edit@0.22.27" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#toml_datetime@0.6.11", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#serde@1.0.228" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#toml_edit@0.22.27", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#indexmap@2.13.0", + "registry+https://github.com/rust-lang/crates.io-index#serde@1.0.228", + "registry+https://github.com/rust-lang/crates.io-index#serde_spanned@0.6.9", + "registry+https://github.com/rust-lang/crates.io-index#toml_datetime@0.6.11", + "registry+https://github.com/rust-lang/crates.io-index#toml_write@0.1.2", + "registry+https://github.com/rust-lang/crates.io-index#winnow@0.7.14" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#toml_write@0.1.2", + "dependsOn": [] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#tracing-attributes@0.1.31", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#proc-macro2@1.0.106", + "registry+https://github.com/rust-lang/crates.io-index#quote@1.0.44", + "registry+https://github.com/rust-lang/crates.io-index#syn@2.0.117" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#tracing-core@0.1.36", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#once_cell@1.21.3" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#tracing@0.1.44", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#pin-project-lite@0.2.16", + "registry+https://github.com/rust-lang/crates.io-index#tracing-attributes@0.1.31", + "registry+https://github.com/rust-lang/crates.io-index#tracing-core@0.1.36" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#unicode-ident@1.0.24", + "dependsOn": [] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#winnow@0.7.14", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#memchr@2.8.0" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#xattr@1.6.1", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#rustix@1.1.3" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#zmij@1.0.21", + "dependsOn": [] + } + ] +} \ No newline at end of file diff --git a/crates/karapace-runtime/src/backend.rs b/crates/karapace-runtime/src/backend.rs new file mode 100644 index 0000000..23cc528 --- /dev/null +++ b/crates/karapace-runtime/src/backend.rs @@ -0,0 +1,82 @@ +use crate::RuntimeError; +use karapace_schema::{NormalizedManifest, ResolutionResult}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RuntimeSpec { + pub env_id: String, + pub root_path: String, + pub overlay_path: String, + pub store_root: String, + pub manifest: NormalizedManifest, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RuntimeStatus { + pub env_id: String, + pub running: bool, + pub pid: Option, +} + +pub trait RuntimeBackend: Send + Sync { + fn name(&self) -> &str; + + fn available(&self) -> bool; + + /// Resolve dependencies: download/identify the base image and query the + /// package manager for exact versions of each requested package. + /// Returns a ResolutionResult with content digest and pinned versions. + fn resolve(&self, spec: &RuntimeSpec) -> Result; + + fn build(&self, spec: &RuntimeSpec) -> Result<(), RuntimeError>; + + fn enter(&self, spec: &RuntimeSpec) -> Result<(), RuntimeError>; + + fn exec( + &self, + _spec: &RuntimeSpec, + _command: &[String], + ) -> Result { + Err(RuntimeError::ExecFailed(format!( + "exec not supported by {} backend", + self.name() + ))) + } + + fn destroy(&self, spec: &RuntimeSpec) -> Result<(), RuntimeError>; + + fn status(&self, env_id: &str) -> Result; +} + +pub fn select_backend( + name: &str, + store_root: &str, +) -> Result, RuntimeError> { + match name { + "namespace" => Ok(Box::new( + crate::namespace::NamespaceBackend::with_store_root(store_root), + )), + "oci" => Ok(Box::new(crate::oci::OciBackend::with_store_root( + store_root, + ))), + "mock" => Ok(Box::new(crate::mock::MockBackend::new())), + other => Err(RuntimeError::BackendUnavailable(other.to_owned())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn select_valid_backends() { + assert!(select_backend("namespace", "/tmp/test-store").is_ok()); + assert!(select_backend("oci", "/tmp/test-store").is_ok()); + assert!(select_backend("mock", "/tmp/test-store").is_ok()); + } + + #[test] + fn select_invalid_backend_fails() { + assert!(select_backend("nonexistent", "/tmp/test-store").is_err()); + } +} diff --git a/crates/karapace-runtime/src/export.rs b/crates/karapace-runtime/src/export.rs new file mode 100644 index 0000000..b15e97c --- /dev/null +++ b/crates/karapace-runtime/src/export.rs @@ -0,0 +1,232 @@ +use crate::RuntimeError; +use std::path::{Path, PathBuf}; + +pub struct ExportedApp { + pub name: String, + pub desktop_file: PathBuf, + pub exec_command: String, +} + +fn default_desktop_dir() -> Result { + if let Ok(home) = std::env::var("HOME") { + Ok(PathBuf::from(home).join(".local/share/applications")) + } else { + Err(RuntimeError::ExecFailed( + "HOME environment variable not set".to_owned(), + )) + } +} + +fn desktop_file_name(env_id: &str, app_name: &str) -> String { + let short_id = &env_id[..12.min(env_id.len())]; + format!("karapace-{short_id}-{app_name}.desktop") +} + +fn desktop_prefix(env_id: &str) -> String { + let short_id = &env_id[..12.min(env_id.len())]; + format!("karapace-{short_id}-") +} + +fn write_desktop_entry( + desktop_dir: &Path, + env_id: &str, + app_name: &str, + binary_path: &str, + karapace_bin: &str, + store_path: &str, +) -> Result { + let short_id = &env_id[..12.min(env_id.len())]; + + std::fs::create_dir_all(desktop_dir)?; + + let desktop_id = desktop_file_name(env_id, app_name); + let desktop_path = desktop_dir.join(&desktop_id); + + let exec_cmd = format!("{karapace_bin} --store {store_path} enter {short_id} -- {binary_path}"); + + let icon = app_name; + + let contents = format!( + "[Desktop Entry]\n\ + Type=Application\n\ + Name={app_name} (Karapace {short_id})\n\ + Exec={exec_cmd}\n\ + Icon={icon}\n\ + Terminal=false\n\ + Categories=Karapace;\n\ + X-Karapace-EnvId={env_id}\n\ + X-Karapace-Store={store_path}\n\ + Comment=Launched inside Karapace environment {short_id}\n" + ); + + std::fs::write(&desktop_path, &contents)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&desktop_path, std::fs::Permissions::from_mode(0o755)); + } + + Ok(ExportedApp { + name: app_name.to_owned(), + desktop_file: desktop_path, + exec_command: exec_cmd, + }) +} + +pub fn export_app( + env_id: &str, + app_name: &str, + binary_path: &str, + karapace_bin: &str, + store_path: &str, +) -> Result { + let desktop_dir = default_desktop_dir()?; + write_desktop_entry( + &desktop_dir, + env_id, + app_name, + binary_path, + karapace_bin, + store_path, + ) +} + +pub fn unexport_app(env_id: &str, app_name: &str) -> Result<(), RuntimeError> { + let desktop_dir = default_desktop_dir()?; + remove_desktop_entry(&desktop_dir, env_id, app_name) +} + +fn remove_desktop_entry( + desktop_dir: &Path, + env_id: &str, + app_name: &str, +) -> Result<(), RuntimeError> { + let desktop_id = desktop_file_name(env_id, app_name); + let desktop_path = desktop_dir.join(&desktop_id); + if desktop_path.exists() { + std::fs::remove_file(&desktop_path)?; + } + Ok(()) +} + +pub fn unexport_all(env_id: &str) -> Result, RuntimeError> { + let desktop_dir = default_desktop_dir()?; + remove_all_entries(&desktop_dir, env_id) +} + +fn remove_all_entries(desktop_dir: &Path, env_id: &str) -> Result, RuntimeError> { + let prefix = desktop_prefix(env_id); + let mut removed = Vec::new(); + + if desktop_dir.exists() { + for entry in std::fs::read_dir(desktop_dir)? { + let entry = entry?; + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with(&prefix) && name_str.ends_with(".desktop") { + std::fs::remove_file(entry.path())?; + removed.push(name_str.to_string()); + } + } + } + + Ok(removed) +} + +pub fn list_exported(env_id: &str) -> Result, RuntimeError> { + let desktop_dir = default_desktop_dir()?; + list_entries(&desktop_dir, env_id) +} + +fn list_entries(desktop_dir: &Path, env_id: &str) -> Result, RuntimeError> { + let prefix = desktop_prefix(env_id); + let mut apps = Vec::new(); + + if desktop_dir.exists() { + for entry in std::fs::read_dir(desktop_dir)? { + let entry = entry?; + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with(&prefix) && name_str.ends_with(".desktop") { + let app_name = name_str + .strip_prefix(&prefix) + .and_then(|s| s.strip_suffix(".desktop")) + .unwrap_or(&name_str) + .to_string(); + apps.push(app_name); + } + } + } + + Ok(apps) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_desktop_dir() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let apps = dir.path().join(".local/share/applications"); + (dir, apps) + } + + const TEST_ENV_ID: &str = "abc123def456789012345678901234567890123456789012345678901234"; + + #[test] + fn export_unexport_roundtrip() { + let (_dir, apps) = test_desktop_dir(); + + let result = write_desktop_entry( + &apps, + TEST_ENV_ID, + "test-app", + "/usr/bin/test-app", + "/usr/bin/karapace", + "/tmp/store", + ) + .unwrap(); + + assert!(result.desktop_file.exists()); + let contents = std::fs::read_to_string(&result.desktop_file).unwrap(); + assert!(contents.contains("X-Karapace-EnvId=")); + assert!(contents.contains("test-app")); + + let found = list_entries(&apps, TEST_ENV_ID).unwrap(); + assert_eq!(found, vec!["test-app"]); + + remove_desktop_entry(&apps, TEST_ENV_ID, "test-app").unwrap(); + assert!(!result.desktop_file.exists()); + } + + #[test] + fn unexport_all_cleans_up() { + let (_dir, apps) = test_desktop_dir(); + + write_desktop_entry( + &apps, + TEST_ENV_ID, + "app1", + "/usr/bin/app1", + "/usr/bin/karapace", + "/tmp/store", + ) + .unwrap(); + write_desktop_entry( + &apps, + TEST_ENV_ID, + "app2", + "/usr/bin/app2", + "/usr/bin/karapace", + "/tmp/store", + ) + .unwrap(); + + let removed = remove_all_entries(&apps, TEST_ENV_ID).unwrap(); + assert_eq!(removed.len(), 2); + + let found = list_entries(&apps, TEST_ENV_ID).unwrap(); + assert!(found.is_empty()); + } +} diff --git a/crates/karapace-runtime/src/host.rs b/crates/karapace-runtime/src/host.rs new file mode 100644 index 0000000..e2e7f01 --- /dev/null +++ b/crates/karapace-runtime/src/host.rs @@ -0,0 +1,267 @@ +use crate::sandbox::BindMount; +use karapace_schema::NormalizedManifest; +use std::path::{Path, PathBuf}; + +pub struct HostIntegration { + pub bind_mounts: Vec, + pub env_vars: Vec<(String, String)>, +} + +#[allow(clippy::too_many_lines)] +pub fn compute_host_integration(manifest: &NormalizedManifest) -> HostIntegration { + let mut bind_mounts = Vec::new(); + let mut env_vars = Vec::new(); + + // Wayland display + if let Ok(wayland) = std::env::var("WAYLAND_DISPLAY") { + env_vars.push(("WAYLAND_DISPLAY".to_owned(), wayland)); + } + + // X11 display + if let Ok(display) = std::env::var("DISPLAY") { + env_vars.push(("DISPLAY".to_owned(), display)); + if Path::new("/tmp/.X11-unix").exists() { + bind_mounts.push(BindMount { + source: PathBuf::from("/tmp/.X11-unix"), + target: PathBuf::from("/tmp/.X11-unix"), + read_only: true, + }); + } + // Xauthority + if let Ok(xauth) = std::env::var("XAUTHORITY") { + if Path::new(&xauth).exists() { + bind_mounts.push(BindMount { + source: PathBuf::from(&xauth), + target: PathBuf::from(&xauth), + read_only: true, + }); + env_vars.push(("XAUTHORITY".to_owned(), xauth)); + } + } + } + + // XDG_RUNTIME_DIR sockets + if let Ok(xdg_run) = std::env::var("XDG_RUNTIME_DIR") { + let xdg_path = PathBuf::from(&xdg_run); + env_vars.push(("XDG_RUNTIME_DIR".to_owned(), xdg_run.clone())); + + // PipeWire socket + let pipewire = xdg_path.join("pipewire-0"); + if pipewire.exists() { + bind_mounts.push(BindMount { + source: pipewire.clone(), + target: pipewire, + read_only: false, + }); + } + + // PulseAudio socket + let pulse = xdg_path.join("pulse/native"); + if pulse.exists() { + bind_mounts.push(BindMount { + source: pulse.clone(), + target: pulse, + read_only: false, + }); + } + + // D-Bus session socket + let dbus = xdg_path.join("bus"); + if dbus.exists() { + bind_mounts.push(BindMount { + source: dbus.clone(), + target: dbus, + read_only: false, + }); + env_vars.push(( + "DBUS_SESSION_BUS_ADDRESS".to_owned(), + format!("unix:path={xdg_run}/bus"), + )); + } + + // Wayland socket + let wayland_sock = xdg_path.join("wayland-0"); + if wayland_sock.exists() { + bind_mounts.push(BindMount { + source: wayland_sock.clone(), + target: wayland_sock, + read_only: false, + }); + } + } + + // GPU passthrough + if manifest.hardware_gpu { + // DRI render nodes + if Path::new("/dev/dri").exists() { + bind_mounts.push(BindMount { + source: PathBuf::from("/dev/dri"), + target: PathBuf::from("/dev/dri"), + read_only: false, + }); + } + // Nvidia devices + for dev in &[ + "/dev/nvidia0", + "/dev/nvidiactl", + "/dev/nvidia-modeset", + "/dev/nvidia-uvm", + ] { + if Path::new(dev).exists() { + bind_mounts.push(BindMount { + source: PathBuf::from(dev), + target: PathBuf::from(dev), + read_only: false, + }); + } + } + } + + // Audio passthrough + if manifest.hardware_audio && Path::new("/dev/snd").exists() { + bind_mounts.push(BindMount { + source: PathBuf::from("/dev/snd"), + target: PathBuf::from("/dev/snd"), + read_only: false, + }); + } + + // Manifest-declared mounts + for mount in &manifest.mounts { + let host_path = expand_path(&mount.host_path); + bind_mounts.push(BindMount { + source: host_path, + target: PathBuf::from(&mount.container_path), + read_only: false, + }); + } + + // Standard env vars to propagate (safe, non-secret variables only). + // Security-sensitive vars like SSH_AUTH_SOCK and GPG_AGENT_INFO are + // excluded here — they are in SecurityPolicy.denied_env_vars. + // Users who need SSH agent forwarding should declare an explicit mount. + for key in &[ + "TERM", "LANG", "LANGUAGE", "LC_ALL", "SHELL", "EDITOR", "VISUAL", + ] { + if let Ok(val) = std::env::var(key) { + if !env_vars.iter().any(|(k, _)| k == *key) { + env_vars.push((key.to_string(), val)); + } + } + } + + // Font config and themes + for dir in &["/usr/share/fonts", "/usr/share/icons", "/usr/share/themes"] { + if Path::new(dir).exists() { + bind_mounts.push(BindMount { + source: PathBuf::from(dir), + target: PathBuf::from(dir), + read_only: true, + }); + } + } + + HostIntegration { + bind_mounts, + env_vars, + } +} + +fn expand_path(path: &str) -> PathBuf { + if let Some(stripped) = path.strip_prefix("~/") { + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(stripped); + } + } + if path.starts_with("./") || path == "." { + if let Ok(cwd) = std::env::current_dir() { + return cwd.join(path.strip_prefix("./").unwrap_or(path)); + } + } + PathBuf::from(path) +} + +#[cfg(test)] +mod tests { + use super::*; + use karapace_schema::parse_manifest_str; + + #[test] + fn host_integration_includes_gpu_when_requested() { + let manifest = parse_manifest_str( + r#" +manifest_version = 1 +[base] +image = "rolling" +[hardware] +gpu = true +"#, + ) + .unwrap() + .normalize() + .unwrap(); + + let hi = compute_host_integration(&manifest); + let has_dri = hi + .bind_mounts + .iter() + .any(|m| m.source.as_path() == Path::new("/dev/dri")); + // Only assert if the device exists on this system + if Path::new("/dev/dri").exists() { + assert!(has_dri); + } + } + + #[test] + fn host_integration_excludes_gpu_when_not_requested() { + let manifest = parse_manifest_str( + r#" +manifest_version = 1 +[base] +image = "rolling" +[hardware] +gpu = false +"#, + ) + .unwrap() + .normalize() + .unwrap(); + + let hi = compute_host_integration(&manifest); + let has_dri = hi + .bind_mounts + .iter() + .any(|m| m.source.as_path() == Path::new("/dev/dri")); + assert!(!has_dri); + } + + #[test] + fn manifest_mounts_included() { + let manifest = parse_manifest_str( + r#" +manifest_version = 1 +[base] +image = "rolling" +[mounts] +workspace = "/tmp/test-src:/workspace" +"#, + ) + .unwrap() + .normalize() + .unwrap(); + + let hi = compute_host_integration(&manifest); + assert!(hi + .bind_mounts + .iter() + .any(|m| m.target.as_path() == Path::new("/workspace"))); + } + + #[test] + fn expand_tilde_path() { + let expanded = expand_path("~/projects"); + if let Ok(home) = std::env::var("HOME") { + assert_eq!(expanded, PathBuf::from(home).join("projects")); + } + } +} diff --git a/crates/karapace-runtime/src/image.rs b/crates/karapace-runtime/src/image.rs new file mode 100644 index 0000000..d302c77 --- /dev/null +++ b/crates/karapace-runtime/src/image.rs @@ -0,0 +1,715 @@ +use crate::RuntimeError; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const LXC_IMAGE_BASE: &str = "https://images.linuxcontainers.org/images"; + +#[derive(Debug, Clone)] +pub enum ImageSource { + OpenSuse { variant: String }, + Ubuntu { codename: String }, + Debian { codename: String }, + Fedora { version: String }, + Arch, + Custom { url: String }, +} + +#[derive(Debug, Clone)] +pub struct ResolvedImage { + pub source: ImageSource, + pub cache_key: String, + pub display_name: String, +} + +#[allow(clippy::too_many_lines)] +pub fn resolve_image(name: &str) -> Result { + let name = name.trim().to_lowercase(); + let (source, cache_key, display_name) = match name.as_str() { + "rolling" | "opensuse" | "opensuse/tumbleweed" | "tumbleweed" => ( + ImageSource::OpenSuse { + variant: "tumbleweed".to_owned(), + }, + "opensuse-tumbleweed".to_owned(), + "openSUSE Tumbleweed".to_owned(), + ), + "opensuse/leap" | "leap" => ( + ImageSource::OpenSuse { + variant: "15.6".to_owned(), + }, + "opensuse-leap-15.6".to_owned(), + "openSUSE Leap 15.6".to_owned(), + ), + "ubuntu" | "ubuntu/24.04" | "ubuntu/noble" => ( + ImageSource::Ubuntu { + codename: "noble".to_owned(), + }, + "ubuntu-noble".to_owned(), + "Ubuntu 24.04 (Noble)".to_owned(), + ), + "ubuntu/22.04" | "ubuntu/jammy" => ( + ImageSource::Ubuntu { + codename: "jammy".to_owned(), + }, + "ubuntu-jammy".to_owned(), + "Ubuntu 22.04 (Jammy)".to_owned(), + ), + "debian" | "debian/bookworm" => ( + ImageSource::Debian { + codename: "bookworm".to_owned(), + }, + "debian-bookworm".to_owned(), + "Debian Bookworm".to_owned(), + ), + "debian/trixie" => ( + ImageSource::Debian { + codename: "trixie".to_owned(), + }, + "debian-trixie".to_owned(), + "Debian Trixie".to_owned(), + ), + "fedora" | "fedora/41" => ( + ImageSource::Fedora { + version: "41".to_owned(), + }, + "fedora-41".to_owned(), + "Fedora 41".to_owned(), + ), + "fedora/40" => ( + ImageSource::Fedora { + version: "40".to_owned(), + }, + "fedora-40".to_owned(), + "Fedora 40".to_owned(), + ), + "fedora/42" => ( + ImageSource::Fedora { + version: "42".to_owned(), + }, + "fedora-42".to_owned(), + "Fedora 42".to_owned(), + ), + "debian/sid" => ( + ImageSource::Debian { + codename: "sid".to_owned(), + }, + "debian-sid".to_owned(), + "Debian Sid".to_owned(), + ), + "ubuntu/24.10" | "ubuntu/oracular" => ( + ImageSource::Ubuntu { + codename: "oracular".to_owned(), + }, + "ubuntu-oracular".to_owned(), + "Ubuntu 24.10 (Oracular)".to_owned(), + ), + "ubuntu/20.04" | "ubuntu/focal" => ( + ImageSource::Ubuntu { + codename: "focal".to_owned(), + }, + "ubuntu-focal".to_owned(), + "Ubuntu 20.04 (Focal)".to_owned(), + ), + "arch" | "archlinux" => ( + ImageSource::Arch, + "archlinux".to_owned(), + "Arch Linux".to_owned(), + ), + other => { + if other.starts_with("http://") || other.starts_with("https://") { + ( + ImageSource::Custom { + url: other.to_owned(), + }, + format!("custom-{}", blake3::hash(other.as_bytes()).to_hex()), + format!("Custom ({other})"), + ) + } else { + return Err(RuntimeError::ImageNotFound(format!( + "unknown image '{other}'. Supported: rolling, opensuse/tumbleweed, opensuse/leap, \ + ubuntu, ubuntu/24.04, ubuntu/22.04, ubuntu/20.04, \ + debian, debian/bookworm, debian/trixie, debian/sid, \ + fedora, fedora/40, fedora/41, fedora/42, \ + arch, archlinux, or a URL" + ))); + } + } + }; + + Ok(ResolvedImage { + source, + cache_key, + display_name, + }) +} + +fn lxc_rootfs_url(distro: &str, variant: &str) -> String { + format!("{LXC_IMAGE_BASE}/{distro}/{variant}/amd64/default/") +} + +fn fetch_latest_build(index_url: &str) -> Result { + let output = Command::new("curl") + .args(["-fsSL", "--max-time", "30", index_url]) + .output() + .map_err(|e| RuntimeError::ExecFailed(format!("curl failed: {e}")))?; + + if !output.status.success() { + return Err(RuntimeError::ExecFailed(format!( + "failed to fetch image index from {index_url}: {}", + String::from_utf8_lossy(&output.stderr) + ))); + } + + let body = String::from_utf8_lossy(&output.stdout); + // LXC image server uses build dates like "20260220_04:20/" or URL-encoded "20260220_04%3A20/" + let mut builds: Vec = body + .lines() + .filter_map(|line| { + let href = line.split("href=\"").nth(1)?; + let raw = href.split('"').next()?; + let name = raw.trim_end_matches('/'); + // Decode %3A -> : for comparison, but keep the raw form for URL construction + let decoded = name.replace("%3A", ":"); + // Build dates start with digits (e.g. "20260220_04:20") + if decoded.starts_with(|c: char| c.is_ascii_digit()) && decoded.len() >= 8 { + Some(decoded) + } else { + None + } + }) + .collect(); + builds.sort(); + + builds + .last() + .cloned() + .ok_or_else(|| RuntimeError::ExecFailed(format!("no builds found at {index_url}"))) +} + +fn url_encode_build(build: &str) -> String { + build.replace(':', "%3A") +} + +fn build_download_url(base_idx: &str) -> Result { + let build = fetch_latest_build(base_idx)?; + let encoded = url_encode_build(&build); + Ok(format!("{base_idx}{encoded}/rootfs.tar.xz")) +} + +fn download_url(source: &ImageSource) -> Result { + match source { + ImageSource::OpenSuse { variant } => { + let idx = if variant == "tumbleweed" { + lxc_rootfs_url("opensuse", "tumbleweed") + } else { + lxc_rootfs_url("opensuse", variant) + }; + build_download_url(&idx) + } + ImageSource::Ubuntu { codename } => { + let idx = lxc_rootfs_url("ubuntu", codename); + build_download_url(&idx) + } + ImageSource::Debian { codename } => { + let idx = lxc_rootfs_url("debian", codename); + build_download_url(&idx) + } + ImageSource::Fedora { version } => { + let idx = lxc_rootfs_url("fedora", version); + build_download_url(&idx) + } + ImageSource::Arch => { + let idx = lxc_rootfs_url("archlinux", "current"); + build_download_url(&idx) + } + ImageSource::Custom { url } => Ok(url.clone()), + } +} + +pub struct ImageCache { + cache_dir: PathBuf, +} + +impl ImageCache { + pub fn new(store_root: &Path) -> Self { + Self { + cache_dir: store_root.join("images"), + } + } + + pub fn rootfs_path(&self, cache_key: &str) -> PathBuf { + self.cache_dir.join(cache_key).join("rootfs") + } + + pub fn is_cached(&self, cache_key: &str) -> bool { + self.rootfs_path(cache_key).join("etc").exists() + } + + pub fn ensure_image( + &self, + resolved: &ResolvedImage, + progress: &dyn Fn(&str), + ) -> Result { + let rootfs = self.rootfs_path(&resolved.cache_key); + if self.is_cached(&resolved.cache_key) { + progress(&format!("using cached image: {}", resolved.display_name)); + return Ok(rootfs); + } + + std::fs::create_dir_all(&rootfs)?; + + progress(&format!( + "resolving image URL for {}...", + resolved.display_name + )); + let url = download_url(&resolved.source)?; + + let tarball = self + .cache_dir + .join(&resolved.cache_key) + .join("rootfs.tar.xz"); + progress(&format!("downloading {url}...")); + + let status = Command::new("curl") + .args([ + "-fSL", + "--progress-bar", + "--max-time", + "600", + "-o", + &tarball.to_string_lossy(), + &url, + ]) + .status() + .map_err(|e| RuntimeError::ExecFailed(format!("curl download failed: {e}")))?; + + if !status.success() { + let _ = std::fs::remove_dir_all(self.cache_dir.join(&resolved.cache_key)); + return Err(RuntimeError::ExecFailed(format!( + "failed to download image from {url}" + ))); + } + + progress("extracting rootfs..."); + let status = Command::new("tar") + .args([ + "xf", + &tarball.to_string_lossy(), + "-C", + &rootfs.to_string_lossy(), + "--no-same-owner", + "--no-same-permissions", + "--exclude=dev/*", + ]) + .status() + .map_err(|e| RuntimeError::ExecFailed(format!("tar extract failed: {e}")))?; + + if !status.success() { + let _ = force_remove(&self.cache_dir.join(&resolved.cache_key)); + return Err(RuntimeError::ExecFailed( + "failed to extract rootfs tarball".to_owned(), + )); + } + + // Ensure all extracted files are user-readable and directories are user-writable. + // LXC rootfs tarballs contain setuid binaries and root-owned restrictive permissions. + let _ = Command::new("chmod") + .args(["-R", "u+rwX", &rootfs.to_string_lossy()]) + .status(); + + let _ = std::fs::remove_file(&tarball); + + // Compute and store the content digest for future integrity verification. + progress("computing image digest..."); + let digest = compute_image_digest(&rootfs)?; + let digest_file = self + .cache_dir + .join(&resolved.cache_key) + .join("rootfs.blake3"); + std::fs::write(&digest_file, &digest)?; + + progress(&format!("image {} ready", resolved.display_name)); + Ok(rootfs) + } + + /// Verify the integrity of a cached image by recomputing its digest + /// and comparing it to the stored value. Returns an error if the image + /// has been corrupted or tampered with. + pub fn verify_image(&self, cache_key: &str) -> Result<(), RuntimeError> { + let rootfs = self.rootfs_path(cache_key); + let digest_file = self.cache_dir.join(cache_key).join("rootfs.blake3"); + + if !digest_file.exists() { + // No stored digest (pre-verification image); compute and store one now + let digest = compute_image_digest(&rootfs)?; + std::fs::write(&digest_file, &digest)?; + return Ok(()); + } + + let stored = std::fs::read_to_string(&digest_file) + .map_err(|e| RuntimeError::ExecFailed(format!("failed to read digest file: {e}")))?; + let current = compute_image_digest(&rootfs)?; + + if stored.trim() != current.trim() { + return Err(RuntimeError::ExecFailed(format!( + "image integrity check failed for {cache_key}: stored digest {stored} != computed {current}" + ))); + } + + Ok(()) + } +} + +/// Compute a content digest (blake3) of a rootfs directory. +/// +/// Hashes the sorted list of file paths + sizes for a deterministic +/// fingerprint of the image content without reading every byte. +pub fn compute_image_digest(rootfs: &Path) -> Result { + // Hash the tarball if it exists, otherwise hash a manifest of the rootfs + let tarball = rootfs.parent().map(|p| p.join("rootfs.tar.xz")); + if let Some(ref tb) = tarball { + if tb.exists() { + let data = std::fs::read(tb) + .map_err(|e| RuntimeError::ExecFailed(format!("failed to read tarball: {e}")))?; + return Ok(blake3::hash(&data).to_hex().to_string()); + } + } + + // Fallback: hash a deterministic file listing + let mut hasher = blake3::Hasher::new(); + let mut entries = Vec::new(); + collect_file_entries(rootfs, rootfs, &mut entries)?; + entries.sort(); + for entry in &entries { + hasher.update(entry.as_bytes()); + } + Ok(hasher.finalize().to_hex().to_string()) +} + +fn collect_file_entries( + base: &Path, + dir: &Path, + entries: &mut Vec, +) -> Result<(), RuntimeError> { + let Ok(listing) = std::fs::read_dir(dir) else { + return Ok(()); + }; + for entry in listing { + let entry = entry?; + let ft = entry.file_type()?; + let rel = entry + .path() + .strip_prefix(base) + .unwrap_or(&entry.path()) + .to_string_lossy() + .to_string(); + if ft.is_file() { + let len = entry.metadata().map(|m| m.len()).unwrap_or(0); + entries.push(format!("{rel}:{len}")); + } else if ft.is_dir() { + entries.push(format!("{rel}/")); + collect_file_entries(base, &entry.path(), entries)?; + } + } + Ok(()) +} + +/// Build a command to query installed package versions from the container. +pub fn query_versions_command(pkg_manager: &str, packages: &[String]) -> Vec { + match pkg_manager { + "apt" => { + // dpkg-query outputs name\tversion for each installed package + let mut cmd = vec![ + "dpkg-query".to_owned(), + "-W".to_owned(), + "-f".to_owned(), + "${Package}\\t${Version}\\n".to_owned(), + ]; + cmd.extend(packages.iter().cloned()); + cmd + } + "dnf" | "zypper" => { + let mut cmd = vec![ + "rpm".to_owned(), + "-q".to_owned(), + "--qf".to_owned(), + "%{NAME}\\t%{VERSION}-%{RELEASE}\\n".to_owned(), + ]; + cmd.extend(packages.iter().cloned()); + cmd + } + "pacman" => { + // pacman -Q outputs "name version" per line + let mut cmd = vec!["pacman".to_owned(), "-Q".to_owned()]; + cmd.extend(packages.iter().cloned()); + cmd + } + _ => Vec::new(), + } +} + +/// Parse the output of a version query command into (name, version) pairs. +pub fn parse_version_output(pkg_manager: &str, output: &str) -> Vec<(String, String)> { + let mut results = Vec::new(); + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let parts: Vec<&str> = if pkg_manager == "pacman" { + line.splitn(2, ' ').collect() + } else { + line.splitn(2, '\t').collect() + }; + if parts.len() == 2 { + results.push((parts[0].to_owned(), parts[1].to_owned())); + } + } + results +} + +pub fn force_remove(path: &Path) -> Result<(), RuntimeError> { + if path.exists() { + let _ = Command::new("chmod") + .args(["-R", "u+rwX", &path.to_string_lossy()]) + .status(); + std::fs::remove_dir_all(path)?; + } + Ok(()) +} + +pub fn detect_package_manager(rootfs: &Path) -> Option<&'static str> { + if rootfs.join("usr/bin/apt-get").exists() || rootfs.join("usr/bin/apt").exists() { + Some("apt") + } else if rootfs.join("usr/bin/dnf").exists() || rootfs.join("usr/bin/dnf5").exists() { + Some("dnf") + } else if rootfs.join("usr/bin/zypper").exists() { + Some("zypper") + } else if rootfs.join("usr/bin/pacman").exists() { + Some("pacman") + } else { + None + } +} + +pub fn install_packages_command(pkg_manager: &str, packages: &[String]) -> Vec { + if packages.is_empty() { + return Vec::new(); + } + let mut cmd = Vec::new(); + match pkg_manager { + "apt" => { + cmd.push("apt-get".to_owned()); + cmd.push("install".to_owned()); + cmd.push("-y".to_owned()); + cmd.push("--no-install-recommends".to_owned()); + cmd.extend(packages.iter().cloned()); + } + "dnf" => { + cmd.push("dnf".to_owned()); + cmd.push("install".to_owned()); + cmd.push("-y".to_owned()); + cmd.push("--setopt=install_weak_deps=False".to_owned()); + cmd.extend(packages.iter().cloned()); + } + "zypper" => { + cmd.push("zypper".to_owned()); + cmd.push("--non-interactive".to_owned()); + cmd.push("install".to_owned()); + cmd.push("--no-recommends".to_owned()); + cmd.extend(packages.iter().cloned()); + } + "pacman" => { + cmd.push("pacman".to_owned()); + cmd.push("-S".to_owned()); + cmd.push("--noconfirm".to_owned()); + cmd.push("--needed".to_owned()); + cmd.extend(packages.iter().cloned()); + } + _ => {} + } + cmd +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_known_images() { + assert!(resolve_image("rolling").is_ok()); + assert!(resolve_image("ubuntu/24.04").is_ok()); + assert!(resolve_image("debian/bookworm").is_ok()); + assert!(resolve_image("fedora/41").is_ok()); + assert!(resolve_image("archlinux").is_ok()); + } + + #[test] + fn resolve_unknown_image_fails() { + assert!(resolve_image("not-a-distro").is_err()); + } + + #[test] + fn resolve_custom_url() { + let r = resolve_image("https://example.com/rootfs.tar.xz").unwrap(); + assert!(r.cache_key.starts_with("custom-")); + } + + #[test] + fn install_commands_correct() { + let pkgs = vec!["git".to_owned(), "cmake".to_owned()]; + let cmd = install_packages_command("apt", &pkgs); + assert_eq!(cmd[0], "apt-get"); + assert!(cmd.contains(&"git".to_owned())); + + let cmd = install_packages_command("zypper", &pkgs); + assert_eq!(cmd[0], "zypper"); + assert!(cmd.contains(&"--non-interactive".to_owned())); + + let cmd = install_packages_command("pacman", &pkgs); + assert_eq!(cmd[0], "pacman"); + } + + #[test] + fn detect_no_pkg_manager_on_empty_dir() { + let dir = tempfile::tempdir().unwrap(); + assert!(detect_package_manager(dir.path()).is_none()); + } + + #[test] + fn parse_apt_version_output() { + let output = "git\t1:2.43.0-1ubuntu7\nclang\t1:18.1.3-1\n"; + let versions = parse_version_output("apt", output); + assert_eq!(versions.len(), 2); + assert_eq!( + versions[0], + ("git".to_owned(), "1:2.43.0-1ubuntu7".to_owned()) + ); + assert_eq!(versions[1], ("clang".to_owned(), "1:18.1.3-1".to_owned())); + } + + #[test] + fn parse_rpm_version_output() { + let output = "git\t2.44.0-1.fc41\ncmake\t3.28.3-1.fc41\n"; + let versions = parse_version_output("zypper", output); + assert_eq!(versions.len(), 2); + assert_eq!(versions[0].0, "git"); + assert_eq!(versions[0].1, "2.44.0-1.fc41"); + } + + #[test] + fn parse_pacman_version_output() { + let output = "git 2.44.0-1\ncmake 3.28.3-1\n"; + let versions = parse_version_output("pacman", output); + assert_eq!(versions.len(), 2); + assert_eq!(versions[0], ("git".to_owned(), "2.44.0-1".to_owned())); + } + + #[test] + fn parse_empty_version_output() { + let versions = parse_version_output("apt", ""); + assert!(versions.is_empty()); + let versions = parse_version_output("apt", "\n\n"); + assert!(versions.is_empty()); + } + + #[test] + fn query_versions_commands_generated() { + let pkgs = vec!["git".to_owned()]; + let cmd = query_versions_command("apt", &pkgs); + assert_eq!(cmd[0], "dpkg-query"); + + let cmd = query_versions_command("zypper", &pkgs); + assert_eq!(cmd[0], "rpm"); + + let cmd = query_versions_command("dnf", &pkgs); + assert_eq!(cmd[0], "rpm"); + + let cmd = query_versions_command("pacman", &pkgs); + assert_eq!(cmd[0], "pacman"); + + let cmd = query_versions_command("unknown", &pkgs); + assert!(cmd.is_empty()); + } + + #[test] + fn compute_digest_of_test_rootfs() { + let dir = tempfile::tempdir().unwrap(); + let rootfs = dir.path().join("rootfs"); + std::fs::create_dir_all(rootfs.join("etc")).unwrap(); + std::fs::write(rootfs.join("etc/hostname"), "test").unwrap(); + std::fs::create_dir_all(rootfs.join("usr/bin")).unwrap(); + std::fs::write(rootfs.join("usr/bin/hello"), "#!/bin/sh\necho hi").unwrap(); + + let digest = compute_image_digest(&rootfs).unwrap(); + assert_eq!(digest.len(), 64); + + // Same content = same digest (determinism) + let digest2 = compute_image_digest(&rootfs).unwrap(); + assert_eq!(digest, digest2); + } + + #[test] + fn detect_apt_package_manager() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("usr/bin")).unwrap(); + std::fs::write(dir.path().join("usr/bin/apt-get"), "").unwrap(); + assert_eq!(detect_package_manager(dir.path()), Some("apt")); + } + + #[test] + fn detect_zypper_package_manager() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("usr/bin")).unwrap(); + std::fs::write(dir.path().join("usr/bin/zypper"), "").unwrap(); + assert_eq!(detect_package_manager(dir.path()), Some("zypper")); + } + + #[test] + fn detect_pacman_package_manager() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("usr/bin")).unwrap(); + std::fs::write(dir.path().join("usr/bin/pacman"), "").unwrap(); + assert_eq!(detect_package_manager(dir.path()), Some("pacman")); + } + + #[test] + fn resolve_all_image_aliases() { + // Verify every documented alias resolves correctly + for alias in &[ + "rolling", + "opensuse", + "opensuse/tumbleweed", + "tumbleweed", + "opensuse/leap", + "leap", + "ubuntu", + "ubuntu/24.04", + "ubuntu/noble", + "ubuntu/22.04", + "ubuntu/jammy", + "ubuntu/20.04", + "ubuntu/focal", + "ubuntu/24.10", + "ubuntu/oracular", + "debian", + "debian/bookworm", + "debian/trixie", + "debian/sid", + "fedora", + "fedora/40", + "fedora/41", + "fedora/42", + "arch", + "archlinux", + ] { + let result = resolve_image(alias); + assert!(result.is_ok(), "failed to resolve alias: {alias}"); + } + } + + #[test] + fn install_empty_packages_returns_empty() { + let cmd = install_packages_command("apt", &[]); + assert!(cmd.is_empty()); + } +} diff --git a/crates/karapace-runtime/src/lib.rs b/crates/karapace-runtime/src/lib.rs new file mode 100644 index 0000000..3c65118 --- /dev/null +++ b/crates/karapace-runtime/src/lib.rs @@ -0,0 +1,46 @@ +//! Runtime backends and sandbox infrastructure for Karapace environments. +//! +//! This crate implements the execution layer: pluggable `RuntimeBackend` trait with +//! namespace (user-namespace + fuse-overlayfs) and OCI (runc) backends, sandbox +//! setup script generation, host integration (GPU, audio, X11/Wayland passthrough), +//! base image resolution, prerequisite checking, and security policy enforcement. + +pub mod backend; +pub mod export; +pub mod host; +pub mod image; +pub mod mock; +pub mod namespace; +pub mod oci; +pub mod prereq; +pub mod sandbox; +pub mod security; +pub mod terminal; + +pub use backend::{select_backend, RuntimeBackend, RuntimeSpec, RuntimeStatus}; +pub use prereq::{check_namespace_prereqs, check_oci_prereqs, format_missing, MissingPrereq}; +pub use security::SecurityPolicy; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum RuntimeError { + #[error("runtime I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("backend '{0}' is not available on this system")] + BackendUnavailable(String), + #[error("environment '{0}' is not running")] + NotRunning(String), + #[error("environment '{0}' is already running")] + AlreadyRunning(String), + #[error("security policy violation: {0}")] + PolicyViolation(String), + #[error("mount not allowed by policy: {0}")] + MountDenied(String), + #[error("device access not allowed: {0}")] + DeviceDenied(String), + #[error("runtime execution failed: {0}")] + ExecFailed(String), + #[error("image not found: {0}")] + ImageNotFound(String), +} diff --git a/crates/karapace-runtime/src/mock.rs b/crates/karapace-runtime/src/mock.rs new file mode 100644 index 0000000..a4ac979 --- /dev/null +++ b/crates/karapace-runtime/src/mock.rs @@ -0,0 +1,255 @@ +use crate::backend::{RuntimeBackend, RuntimeSpec, RuntimeStatus}; +use crate::RuntimeError; +use karapace_schema::{ResolutionResult, ResolvedPackage}; +use std::collections::HashMap; +use std::sync::Mutex; + +pub struct MockBackend { + state: Mutex>, +} + +impl Default for MockBackend { + fn default() -> Self { + Self { + state: Mutex::new(HashMap::new()), + } + } +} + +impl MockBackend { + pub fn new() -> Self { + Self::default() + } +} + +impl RuntimeBackend for MockBackend { + fn name(&self) -> &'static str { + "mock" + } + + fn available(&self) -> bool { + true + } + + fn resolve(&self, spec: &RuntimeSpec) -> Result { + // Mock resolution: deterministic digest from image name, + // packages get version "0.0.0-mock" for deterministic identity. + let base_image_digest = + blake3::hash(format!("mock-image:{}", spec.manifest.base_image).as_bytes()) + .to_hex() + .to_string(); + + let resolved_packages = spec + .manifest + .system_packages + .iter() + .map(|name| ResolvedPackage { + name: name.clone(), + version: "0.0.0-mock".to_owned(), + }) + .collect(); + + Ok(ResolutionResult { + base_image_digest, + resolved_packages, + }) + } + + fn build(&self, spec: &RuntimeSpec) -> Result<(), RuntimeError> { + let mut state = self + .state + .lock() + .map_err(|e| RuntimeError::ExecFailed(format!("mutex poisoned: {e}")))?; + state.insert(spec.env_id.clone(), false); + + let root = std::path::Path::new(&spec.root_path); + if !root.exists() { + std::fs::create_dir_all(root)?; + } + let overlay = std::path::Path::new(&spec.overlay_path); + if !overlay.exists() { + std::fs::create_dir_all(overlay)?; + } + + // Create upper dir with mock filesystem content so engine tests + // exercise the real layer capture path (pack_layer on upper dir). + let upper = overlay.join("upper"); + std::fs::create_dir_all(&upper)?; + std::fs::write( + upper.join(".karapace-mock"), + format!("mock-env:{}", spec.env_id), + )?; + for pkg in &spec.manifest.system_packages { + std::fs::write( + upper.join(format!(".pkg-{pkg}")), + format!("{pkg}@0.0.0-mock"), + )?; + } + + Ok(()) + } + + fn enter(&self, spec: &RuntimeSpec) -> Result<(), RuntimeError> { + let mut state = self + .state + .lock() + .map_err(|e| RuntimeError::ExecFailed(format!("mutex poisoned: {e}")))?; + if state.get(&spec.env_id) == Some(&true) { + return Err(RuntimeError::AlreadyRunning(spec.env_id.clone())); + } + state.insert(spec.env_id.clone(), true); + Ok(()) + } + + fn exec( + &self, + _spec: &RuntimeSpec, + command: &[String], + ) -> Result { + let stdout = format!("mock-exec: {}\n", command.join(" ")); + // Create a real success ExitStatus portably + let success_status = std::process::Command::new("true") + .status() + .unwrap_or_else(|_| { + std::process::Command::new("/bin/true") + .status() + .expect("cannot execute /bin/true") + }); + Ok(std::process::Output { + status: success_status, + stdout: stdout.into_bytes(), + stderr: Vec::new(), + }) + } + + fn destroy(&self, spec: &RuntimeSpec) -> Result<(), RuntimeError> { + let mut state = self + .state + .lock() + .map_err(|e| RuntimeError::ExecFailed(format!("mutex poisoned: {e}")))?; + state.remove(&spec.env_id); + + let overlay = std::path::Path::new(&spec.overlay_path); + if overlay.exists() { + std::fs::remove_dir_all(overlay)?; + } + Ok(()) + } + + fn status(&self, env_id: &str) -> Result { + let state = self + .state + .lock() + .map_err(|e| RuntimeError::ExecFailed(format!("mutex poisoned: {e}")))?; + let running = state.get(env_id).copied().unwrap_or(false); + Ok(RuntimeStatus { + env_id: env_id.to_owned(), + running, + pid: if running { Some(99999) } else { None }, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use karapace_schema::parse_manifest_str; + + fn test_spec(dir: &std::path::Path) -> RuntimeSpec { + let manifest = parse_manifest_str( + r#" +manifest_version = 1 +[base] +image = "rolling" +"#, + ) + .unwrap() + .normalize() + .unwrap(); + + RuntimeSpec { + env_id: "mock-test".to_owned(), + root_path: dir.join("root").to_string_lossy().to_string(), + overlay_path: dir.join("overlay").to_string_lossy().to_string(), + store_root: dir.to_string_lossy().to_string(), + manifest, + } + } + + #[test] + fn mock_resolve_determinism() { + let dir = tempfile::tempdir().unwrap(); + let backend = MockBackend::new(); + let spec = test_spec(dir.path()); + + let r1 = backend.resolve(&spec).unwrap(); + let r2 = backend.resolve(&spec).unwrap(); + + assert_eq!(r1.base_image_digest, r2.base_image_digest); + assert_eq!(r1.resolved_packages.len(), r2.resolved_packages.len()); + for (a, b) in r1.resolved_packages.iter().zip(r2.resolved_packages.iter()) { + assert_eq!(a.name, b.name); + assert_eq!(a.version, b.version); + } + } + + #[test] + fn mock_resolve_with_packages() { + let dir = tempfile::tempdir().unwrap(); + let manifest = parse_manifest_str( + r#" +manifest_version = 1 +[base] +image = "rolling" +[system] +packages = ["git", "clang", "cmake"] +[runtime] +backend = "mock" +"#, + ) + .unwrap() + .normalize() + .unwrap(); + + let spec = RuntimeSpec { + env_id: "mock-pkg-test".to_owned(), + root_path: dir.path().join("root").to_string_lossy().to_string(), + overlay_path: dir.path().join("overlay").to_string_lossy().to_string(), + store_root: dir.path().to_string_lossy().to_string(), + manifest, + }; + + let backend = MockBackend::new(); + let result = backend.resolve(&spec).unwrap(); + + assert_eq!(result.resolved_packages.len(), 3); + assert!(result + .resolved_packages + .iter() + .all(|p| p.version == "0.0.0-mock")); + assert!(result.resolved_packages.iter().any(|p| p.name == "git")); + assert!(!result.base_image_digest.is_empty()); + } + + #[test] + fn mock_lifecycle() { + let dir = tempfile::tempdir().unwrap(); + let backend = MockBackend::new(); + let spec = test_spec(dir.path()); + + backend.build(&spec).unwrap(); + let status = backend.status(&spec.env_id).unwrap(); + assert!(!status.running); + + backend.enter(&spec).unwrap(); + let status = backend.status(&spec.env_id).unwrap(); + assert!(status.running); + assert_eq!(status.pid, Some(99999)); + + assert!(backend.enter(&spec).is_err()); + + backend.destroy(&spec).unwrap(); + let status = backend.status(&spec.env_id).unwrap(); + assert!(!status.running); + } +} diff --git a/crates/karapace-runtime/src/namespace.rs b/crates/karapace-runtime/src/namespace.rs new file mode 100644 index 0000000..267bd6c --- /dev/null +++ b/crates/karapace-runtime/src/namespace.rs @@ -0,0 +1,377 @@ +use crate::backend::{RuntimeBackend, RuntimeSpec, RuntimeStatus}; +use crate::host::compute_host_integration; +use crate::image::{ + compute_image_digest, detect_package_manager, force_remove, install_packages_command, + parse_version_output, query_versions_command, resolve_image, ImageCache, +}; +use crate::sandbox::{ + enter_interactive, exec_in_container, install_packages_in_container, mount_overlay, + setup_container_rootfs, unmount_overlay, SandboxConfig, +}; +use crate::terminal; +use crate::RuntimeError; +use karapace_schema::{ResolutionResult, ResolvedPackage}; +use std::path::{Path, PathBuf}; + +pub struct NamespaceBackend { + store_root: PathBuf, +} + +impl Default for NamespaceBackend { + fn default() -> Self { + Self { + store_root: default_store_root(), + } + } +} + +impl NamespaceBackend { + pub fn new() -> Self { + Self::default() + } + + pub fn with_store_root(store_root: impl Into) -> Self { + Self { + store_root: store_root.into(), + } + } + + fn env_dir(&self, env_id: &str) -> PathBuf { + self.store_root.join("env").join(env_id) + } +} + +impl RuntimeBackend for NamespaceBackend { + fn name(&self) -> &'static str { + "namespace" + } + + fn available(&self) -> bool { + // Check that user namespaces work + let output = std::process::Command::new("unshare") + .args(["--user", "--map-root-user", "--fork", "true"]) + .output(); + matches!(output, Ok(o) if o.status.success()) + } + + fn resolve(&self, spec: &RuntimeSpec) -> Result { + let progress = |msg: &str| { + eprintln!("[karapace] {msg}"); + }; + + // Download/cache the base image + let resolved = resolve_image(&spec.manifest.base_image)?; + let image_cache = ImageCache::new(&self.store_root); + let rootfs = image_cache.ensure_image(&resolved, &progress)?; + + // Compute content digest of the base image + let base_image_digest = compute_image_digest(&rootfs)?; + + // If there are packages to resolve, set up a temporary overlay + // and install+query to get exact versions + let resolved_packages = if spec.manifest.system_packages.is_empty() { + Vec::new() + } else { + let tmp_dir = tempfile::tempdir() + .map_err(|e| RuntimeError::ExecFailed(format!("failed to create temp dir: {e}")))?; + let tmp_env = tmp_dir.path().join("resolve-env"); + std::fs::create_dir_all(&tmp_env)?; + + let mut sandbox = SandboxConfig::new(rootfs.clone(), "resolve-tmp", &tmp_env); + sandbox.isolate_network = false; // need network for package resolution + + mount_overlay(&sandbox)?; + setup_container_rootfs(&sandbox)?; + + // Run resolution inside an inner closure so cleanup always runs, + // even if detect/install/query fails. + let resolve_inner = || -> Result, RuntimeError> { + let pkg_mgr = detect_package_manager(&sandbox.overlay_merged) + .or_else(|| detect_package_manager(&rootfs)) + .ok_or_else(|| { + RuntimeError::ExecFailed( + "no supported package manager found in the image".to_owned(), + ) + })?; + + let install_cmd = install_packages_command(pkg_mgr, &spec.manifest.system_packages); + install_packages_in_container(&sandbox, &install_cmd)?; + + let query_cmd = query_versions_command(pkg_mgr, &spec.manifest.system_packages); + let output = exec_in_container(&sandbox, &query_cmd)?; + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(parse_version_output(pkg_mgr, &stdout)) + }; + + let result = resolve_inner(); + + // Always cleanup: unmount overlay and remove temp directory + let _ = unmount_overlay(&sandbox); + let _ = std::fs::remove_dir_all(&tmp_env); + + let versions = result?; + + // Map back to ResolvedPackage, falling back to "unresolved" if query failed + spec.manifest + .system_packages + .iter() + .map(|name| { + let version = versions + .iter() + .find(|(n, _)| n == name) + .map_or_else(|| "unresolved".to_owned(), |(_, v)| v.clone()); + ResolvedPackage { + name: name.clone(), + version, + } + }) + .collect() + }; + + Ok(ResolutionResult { + base_image_digest, + resolved_packages, + }) + } + + fn build(&self, spec: &RuntimeSpec) -> Result<(), RuntimeError> { + let env_dir = self.env_dir(&spec.env_id); + std::fs::create_dir_all(&env_dir)?; + + let progress = |msg: &str| { + eprintln!("[karapace] {msg}"); + }; + + // Resolve and download the base image + let resolved = resolve_image(&spec.manifest.base_image)?; + let image_cache = ImageCache::new(&self.store_root); + let rootfs = image_cache.ensure_image(&resolved, &progress)?; + + // Set up overlay filesystem + let mut sandbox = SandboxConfig::new(rootfs.clone(), &spec.env_id, &env_dir); + sandbox.isolate_network = spec.manifest.network_isolation; + + mount_overlay(&sandbox)?; + + // Set up container rootfs (create dirs, user, etc.) + setup_container_rootfs(&sandbox)?; + + // Install system packages if any + if !spec.manifest.system_packages.is_empty() { + let pkg_mgr = detect_package_manager(&sandbox.overlay_merged) + .or_else(|| detect_package_manager(&rootfs)) + .ok_or_else(|| { + RuntimeError::ExecFailed( + "no supported package manager found in the image. \ + Supported: apt, dnf, zypper, pacman" + .to_owned(), + ) + })?; + + progress(&format!( + "installing {} packages via {pkg_mgr}...", + spec.manifest.system_packages.len() + )); + + let install_cmd = install_packages_command(pkg_mgr, &spec.manifest.system_packages); + install_packages_in_container(&sandbox, &install_cmd)?; + + progress("packages installed"); + } + + // Unmount overlay after build (will be re-mounted on enter) + unmount_overlay(&sandbox)?; + + // Write state marker + std::fs::write(env_dir.join(".built"), "1")?; + + progress(&format!( + "environment {} built successfully ({} base)", + &spec.env_id[..12.min(spec.env_id.len())], + resolved.display_name + )); + + Ok(()) + } + + fn enter(&self, spec: &RuntimeSpec) -> Result<(), RuntimeError> { + let env_dir = self.env_dir(&spec.env_id); + if !env_dir.join(".built").exists() { + return Err(RuntimeError::ExecFailed(format!( + "environment {} has not been built yet. Run 'karapace build' first.", + &spec.env_id[..12.min(spec.env_id.len())] + ))); + } + + // Resolve image to get rootfs path + let resolved = resolve_image(&spec.manifest.base_image)?; + let image_cache = ImageCache::new(&self.store_root); + let rootfs = image_cache.rootfs_path(&resolved.cache_key); + + if !rootfs.join("etc").exists() { + return Err(RuntimeError::ExecFailed( + "base image rootfs is missing or corrupted. Run 'karapace rebuild'.".to_owned(), + )); + } + + // Create sandbox config + let mut sandbox = SandboxConfig::new(rootfs, &spec.env_id, &env_dir); + sandbox.isolate_network = spec.manifest.network_isolation; + sandbox.hostname = format!("karapace-{}", &spec.env_id[..12.min(spec.env_id.len())]); + + // Compute host integration (Wayland, PipeWire, GPU, etc.) + let host = compute_host_integration(&spec.manifest); + sandbox.bind_mounts.extend(host.bind_mounts); + sandbox.env_vars.extend(host.env_vars); + + // Mount overlay + mount_overlay(&sandbox)?; + + // Set up rootfs + setup_container_rootfs(&sandbox)?; + + // Mark as running + std::fs::write(env_dir.join(".running"), format!("{}", std::process::id()))?; + + // Emit terminal markers + terminal::emit_container_push(&spec.env_id, &sandbox.hostname); + terminal::print_container_banner( + &spec.env_id, + &spec.manifest.base_image, + &sandbox.hostname, + ); + + // Enter the container interactively + let exit_code = enter_interactive(&sandbox); + + // Cleanup + terminal::emit_container_pop(); + terminal::print_container_exit(&spec.env_id); + let _ = std::fs::remove_file(env_dir.join(".running")); + let _ = unmount_overlay(&sandbox); + + match exit_code { + Ok(0) => Ok(()), + Ok(code) => Err(RuntimeError::ExecFailed(format!( + "container shell exited with code {code}" + ))), + Err(e) => Err(e), + } + } + + fn exec( + &self, + spec: &RuntimeSpec, + command: &[String], + ) -> Result { + let env_dir = self.env_dir(&spec.env_id); + if !env_dir.join(".built").exists() { + return Err(RuntimeError::ExecFailed(format!( + "environment {} has not been built yet. Run 'karapace build' first.", + &spec.env_id[..12.min(spec.env_id.len())] + ))); + } + + let resolved = resolve_image(&spec.manifest.base_image)?; + let image_cache = ImageCache::new(&self.store_root); + let rootfs = image_cache.rootfs_path(&resolved.cache_key); + + let mut sandbox = SandboxConfig::new(rootfs, &spec.env_id, &env_dir); + sandbox.isolate_network = spec.manifest.network_isolation; + + let host = compute_host_integration(&spec.manifest); + sandbox.bind_mounts.extend(host.bind_mounts); + sandbox.env_vars.extend(host.env_vars); + + mount_overlay(&sandbox)?; + setup_container_rootfs(&sandbox)?; + + let output = exec_in_container(&sandbox, command); + let _ = unmount_overlay(&sandbox); + + output + } + + fn destroy(&self, spec: &RuntimeSpec) -> Result<(), RuntimeError> { + let env_dir = self.env_dir(&spec.env_id); + + // Unmount overlay if mounted + let sandbox_config = + SandboxConfig::new(PathBuf::from("/nonexistent"), &spec.env_id, &env_dir); + let _ = unmount_overlay(&sandbox_config); + + // Remove environment directory (force_remove handles restrictive permissions) + force_remove(&env_dir)?; + + Ok(()) + } + + fn status(&self, env_id: &str) -> Result { + let env_dir = self.env_dir(env_id); + let running_file = env_dir.join(".running"); + + if running_file.exists() { + let pid_str = std::fs::read_to_string(&running_file).unwrap_or_default(); + let pid = pid_str.trim().parse::().ok(); + if pid.is_none() && !pid_str.trim().is_empty() { + tracing::warn!( + "corrupt .running file for {}: could not parse PID from '{}'", + &env_id[..12.min(env_id.len())], + pid_str.trim() + ); + } + // Check if process is actually alive + if let Some(p) = pid { + let alive = Path::new(&format!("/proc/{p}")).exists(); + if !alive { + let _ = std::fs::remove_file(&running_file); + return Ok(RuntimeStatus { + env_id: env_id.to_owned(), + running: false, + pid: None, + }); + } + return Ok(RuntimeStatus { + env_id: env_id.to_owned(), + running: true, + pid: Some(p), + }); + } + } + + Ok(RuntimeStatus { + env_id: env_id.to_owned(), + running: false, + pid: None, + }) + } +} + +fn default_store_root() -> PathBuf { + if let Ok(home) = std::env::var("HOME") { + PathBuf::from(home).join(".local/share/karapace") + } else { + PathBuf::from("/tmp/karapace") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn namespace_backend_available() { + let backend = NamespaceBackend::new(); + // This test verifies the check runs without panicking. + // Result depends on host system capabilities. + let _ = backend.available(); + } + + #[test] + fn status_reports_not_running_for_nonexistent() { + let dir = tempfile::tempdir().unwrap(); + let backend = NamespaceBackend::with_store_root(dir.path()); + let status = backend.status("nonexistent").unwrap(); + assert!(!status.running); + } +} diff --git a/crates/karapace-runtime/src/oci.rs b/crates/karapace-runtime/src/oci.rs new file mode 100644 index 0000000..dc33550 --- /dev/null +++ b/crates/karapace-runtime/src/oci.rs @@ -0,0 +1,510 @@ +use crate::backend::{RuntimeBackend, RuntimeSpec, RuntimeStatus}; +use crate::host::compute_host_integration; +use crate::image::{ + compute_image_digest, detect_package_manager, force_remove, install_packages_command, + parse_version_output, query_versions_command, resolve_image, ImageCache, +}; +use crate::sandbox::{ + exec_in_container, install_packages_in_container, mount_overlay, setup_container_rootfs, + unmount_overlay, SandboxConfig, +}; +use crate::terminal; +use crate::RuntimeError; +use karapace_schema::{ResolutionResult, ResolvedPackage}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub struct OciBackend { + store_root: PathBuf, +} + +impl Default for OciBackend { + fn default() -> Self { + Self { + store_root: default_store_root(), + } + } +} + +impl OciBackend { + pub fn new() -> Self { + Self::default() + } + + pub fn with_store_root(store_root: impl Into) -> Self { + Self { + store_root: store_root.into(), + } + } + + fn find_runtime() -> Option { + for candidate in &["crun", "runc", "youki"] { + if let Ok(output) = Command::new(candidate).arg("--version").output() { + if output.status.success() { + return Some(candidate.to_string()); + } + } + } + None + } + + fn env_dir(&self, env_id: &str) -> PathBuf { + self.store_root.join("env").join(env_id) + } + + fn generate_oci_spec(config: &SandboxConfig, spec: &RuntimeSpec) -> String { + let uid = config.uid; + let gid = config.gid; + let home = config.home_dir.display().to_string(); + let hostname = &config.hostname; + + let mut env_arr = Vec::new(); + env_arr.push(format!("\"HOME={home}\"")); + env_arr.push(format!("\"USER={}\"", config.username)); + env_arr.push(format!("\"HOSTNAME={hostname}\"")); + env_arr.push("\"TERM=xterm-256color\"".to_owned()); + env_arr.push("\"KARAPACE_ENV=1\"".to_owned()); + for (k, v) in &config.env_vars { + env_arr.push(format!("\"{}={}\"", k, v.replace('"', "\\\""))); + } + + let mut mounts = Vec::new(); + // Standard mounts + mounts.push(r#"{"destination":"/proc","type":"proc","source":"proc"}"#.to_owned()); + mounts.push( + r#"{"destination":"/dev","type":"tmpfs","source":"tmpfs","options":["nosuid","strictatime","mode=755","size=65536k"]}"# + .to_owned(), + ); + mounts.push( + r#"{"destination":"/dev/pts","type":"devpts","source":"devpts","options":["nosuid","noexec","newinstance","ptmxmode=0666","mode=0620"]}"# + .to_owned(), + ); + mounts.push( + r#"{"destination":"/dev/shm","type":"tmpfs","source":"shm","options":["nosuid","noexec","nodev","mode=1777","size=65536k"]}"# + .to_owned(), + ); + mounts.push( + r#"{"destination":"/sys","type":"sysfs","source":"sysfs","options":["nosuid","noexec","nodev","ro"]}"# + .to_owned(), + ); + + // Home bind mount + mounts.push(format!( + r#"{{"destination":"{home}","type":"bind","source":"{home}","options":["rbind","rw"]}}"# + )); + + // resolv.conf + mounts.push( + r#"{"destination":"/etc/resolv.conf","type":"bind","source":"/etc/resolv.conf","options":["bind","ro"]}"# + .to_owned(), + ); + + // Custom bind mounts + for bm in &config.bind_mounts { + let opts = if bm.read_only { + "\"rbind\",\"ro\"" + } else { + "\"rbind\",\"rw\"" + }; + mounts.push(format!( + r#"{{"destination":"{}","type":"bind","source":"{}","options":[{}]}}"#, + bm.target.display(), + bm.source.display(), + opts + )); + } + + let mounts_json = mounts.join(","); + let env_json = env_arr.join(","); + + let network_ns = if spec.manifest.network_isolation { + r#",{"type":"network"}"# + } else { + "" + }; + + let oci_spec = format!( + r#"{{ + "ociVersion": "1.0.2", + "process": {{ + "terminal": true, + "user": {{ "uid": {uid}, "gid": {gid} }}, + "args": ["/bin/bash", "-l"], + "env": [{env_json}], + "cwd": "{home}" + }}, + "root": {{ + "path": "rootfs", + "readonly": false + }}, + "hostname": "{hostname}", + "mounts": [{mounts_json}], + "linux": {{ + "namespaces": [ + {{"type":"pid"}}, + {{"type":"mount"}}, + {{"type":"ipc"}}, + {{"type":"uts"}} + {network_ns} + ], + "uidMappings": [{{ "containerID": 0, "hostID": {uid}, "size": 1 }}], + "gidMappings": [{{ "containerID": 0, "hostID": {gid}, "size": 1 }}] + }} +}}"# + ); + + oci_spec + } +} + +impl RuntimeBackend for OciBackend { + fn name(&self) -> &'static str { + "oci" + } + + fn available(&self) -> bool { + Self::find_runtime().is_some() + } + + fn resolve(&self, spec: &RuntimeSpec) -> Result { + let progress = |msg: &str| { + eprintln!("[karapace/oci] {msg}"); + }; + + let resolved = resolve_image(&spec.manifest.base_image)?; + let image_cache = ImageCache::new(&self.store_root); + let rootfs = image_cache.ensure_image(&resolved, &progress)?; + let base_image_digest = compute_image_digest(&rootfs)?; + + let resolved_packages = if spec.manifest.system_packages.is_empty() { + Vec::new() + } else { + let tmp_dir = tempfile::tempdir() + .map_err(|e| RuntimeError::ExecFailed(format!("failed to create temp dir: {e}")))?; + let tmp_env = tmp_dir.path().join("resolve-env"); + std::fs::create_dir_all(&tmp_env)?; + + let mut sandbox = SandboxConfig::new(rootfs.clone(), "resolve-tmp", &tmp_env); + sandbox.isolate_network = false; + + mount_overlay(&sandbox)?; + setup_container_rootfs(&sandbox)?; + + // Run resolution inside an inner closure so cleanup always runs, + // even if detect/install/query fails. + let resolve_inner = || -> Result, RuntimeError> { + let pkg_mgr = detect_package_manager(&sandbox.overlay_merged) + .or_else(|| detect_package_manager(&rootfs)) + .ok_or_else(|| { + RuntimeError::ExecFailed( + "no supported package manager found in the image".to_owned(), + ) + })?; + + let install_cmd = install_packages_command(pkg_mgr, &spec.manifest.system_packages); + install_packages_in_container(&sandbox, &install_cmd)?; + + let query_cmd = query_versions_command(pkg_mgr, &spec.manifest.system_packages); + let output = exec_in_container(&sandbox, &query_cmd)?; + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(parse_version_output(pkg_mgr, &stdout)) + }; + + let result = resolve_inner(); + + // Always cleanup: unmount overlay and remove temp directory + let _ = unmount_overlay(&sandbox); + let _ = std::fs::remove_dir_all(&tmp_env); + + let versions = result?; + + spec.manifest + .system_packages + .iter() + .map(|name| { + let version = versions + .iter() + .find(|(n, _)| n == name) + .map_or_else(|| "unresolved".to_owned(), |(_, v)| v.clone()); + ResolvedPackage { + name: name.clone(), + version, + } + }) + .collect() + }; + + Ok(ResolutionResult { + base_image_digest, + resolved_packages, + }) + } + + fn build(&self, spec: &RuntimeSpec) -> Result<(), RuntimeError> { + let env_dir = self.env_dir(&spec.env_id); + std::fs::create_dir_all(&env_dir)?; + + let progress = |msg: &str| { + eprintln!("[karapace/oci] {msg}"); + }; + + let resolved = resolve_image(&spec.manifest.base_image)?; + let image_cache = ImageCache::new(&self.store_root); + let rootfs = image_cache.ensure_image(&resolved, &progress)?; + + let mut sandbox = SandboxConfig::new(rootfs.clone(), &spec.env_id, &env_dir); + sandbox.isolate_network = spec.manifest.network_isolation; + + mount_overlay(&sandbox)?; + setup_container_rootfs(&sandbox)?; + + if !spec.manifest.system_packages.is_empty() { + let pkg_mgr = detect_package_manager(&sandbox.overlay_merged) + .or_else(|| detect_package_manager(&rootfs)) + .ok_or_else(|| { + RuntimeError::ExecFailed( + "no supported package manager found in the image".to_owned(), + ) + })?; + + progress(&format!( + "installing {} packages via {pkg_mgr}...", + spec.manifest.system_packages.len() + )); + + let install_cmd = install_packages_command(pkg_mgr, &spec.manifest.system_packages); + install_packages_in_container(&sandbox, &install_cmd)?; + progress("packages installed"); + } + + unmount_overlay(&sandbox)?; + + // Generate OCI bundle config.json + let bundle_dir = env_dir.join("bundle"); + std::fs::create_dir_all(&bundle_dir)?; + + // Symlink rootfs into bundle + let bundle_rootfs = bundle_dir.join("rootfs"); + if !bundle_rootfs.exists() { + #[cfg(unix)] + std::os::unix::fs::symlink(&sandbox.overlay_merged, &bundle_rootfs)?; + } + + std::fs::write(env_dir.join(".built"), "1")?; + + progress(&format!( + "environment {} built (OCI, {} base)", + &spec.env_id[..12.min(spec.env_id.len())], + resolved.display_name + )); + + Ok(()) + } + + fn enter(&self, spec: &RuntimeSpec) -> Result<(), RuntimeError> { + let runtime = Self::find_runtime().ok_or_else(|| { + RuntimeError::BackendUnavailable("no OCI runtime found (crun/runc/youki)".to_owned()) + })?; + + let env_dir = self.env_dir(&spec.env_id); + if !env_dir.join(".built").exists() { + return Err(RuntimeError::ExecFailed(format!( + "environment {} has not been built", + &spec.env_id[..12.min(spec.env_id.len())] + ))); + } + + let resolved = resolve_image(&spec.manifest.base_image)?; + let image_cache = ImageCache::new(&self.store_root); + let rootfs = image_cache.rootfs_path(&resolved.cache_key); + + let mut sandbox = SandboxConfig::new(rootfs, &spec.env_id, &env_dir); + sandbox.isolate_network = spec.manifest.network_isolation; + + let host = compute_host_integration(&spec.manifest); + sandbox.bind_mounts.extend(host.bind_mounts); + sandbox.env_vars.extend(host.env_vars); + + mount_overlay(&sandbox)?; + setup_container_rootfs(&sandbox)?; + + // Write OCI config.json + let bundle_dir = env_dir.join("bundle"); + std::fs::create_dir_all(&bundle_dir)?; + + let bundle_rootfs = bundle_dir.join("rootfs"); + if !bundle_rootfs.exists() { + #[cfg(unix)] + std::os::unix::fs::symlink(&sandbox.overlay_merged, &bundle_rootfs)?; + } + + let oci_config = Self::generate_oci_spec(&sandbox, spec); + std::fs::write(bundle_dir.join("config.json"), &oci_config)?; + + let container_id = format!("karapace-{}", &spec.env_id[..12.min(spec.env_id.len())]); + + std::fs::write(env_dir.join(".running"), format!("{}", std::process::id()))?; + + terminal::emit_container_push(&spec.env_id, &sandbox.hostname); + terminal::print_container_banner( + &spec.env_id, + &spec.manifest.base_image, + &sandbox.hostname, + ); + + let status = Command::new(&runtime) + .args([ + "run", + "--bundle", + &bundle_dir.to_string_lossy(), + &container_id, + ]) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .status() + .map_err(|e| RuntimeError::ExecFailed(format!("{runtime} run failed: {e}")))?; + + terminal::emit_container_pop(); + terminal::print_container_exit(&spec.env_id); + let _ = std::fs::remove_file(env_dir.join(".running")); + let _ = unmount_overlay(&sandbox); + + // Clean up OCI container state + let _ = Command::new(&runtime) + .args(["delete", "--force", &container_id]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + + if status.success() { + Ok(()) + } else { + Err(RuntimeError::ExecFailed(format!( + "OCI runtime exited with code {}", + status.code().unwrap_or(1) + ))) + } + } + + fn exec( + &self, + spec: &RuntimeSpec, + command: &[String], + ) -> Result { + let env_dir = self.env_dir(&spec.env_id); + if !env_dir.join(".built").exists() { + return Err(RuntimeError::ExecFailed(format!( + "environment {} has not been built yet", + &spec.env_id[..12.min(spec.env_id.len())] + ))); + } + + let resolved = resolve_image(&spec.manifest.base_image)?; + let image_cache = ImageCache::new(&self.store_root); + let rootfs = image_cache.rootfs_path(&resolved.cache_key); + + let mut sandbox = SandboxConfig::new(rootfs, &spec.env_id, &env_dir); + sandbox.isolate_network = spec.manifest.network_isolation; + + let host = compute_host_integration(&spec.manifest); + sandbox.bind_mounts.extend(host.bind_mounts); + sandbox.env_vars.extend(host.env_vars); + + mount_overlay(&sandbox)?; + setup_container_rootfs(&sandbox)?; + + let output = exec_in_container(&sandbox, command); + let _ = unmount_overlay(&sandbox); + + output + } + + fn destroy(&self, spec: &RuntimeSpec) -> Result<(), RuntimeError> { + let env_dir = self.env_dir(&spec.env_id); + let sandbox = SandboxConfig::new(PathBuf::from("/nonexistent"), &spec.env_id, &env_dir); + let _ = unmount_overlay(&sandbox); + + // Clean up any lingering OCI containers + if let Some(runtime) = Self::find_runtime() { + let container_id = format!("karapace-{}", &spec.env_id[..12.min(spec.env_id.len())]); + let _ = Command::new(&runtime) + .args(["delete", "--force", &container_id]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + } + + force_remove(&env_dir)?; + Ok(()) + } + + fn status(&self, env_id: &str) -> Result { + let env_dir = self.env_dir(env_id); + let running_file = env_dir.join(".running"); + + if running_file.exists() { + let pid_str = std::fs::read_to_string(&running_file).unwrap_or_default(); + let pid = pid_str.trim().parse::().ok(); + if pid.is_none() && !pid_str.trim().is_empty() { + tracing::warn!( + "corrupt .running file for {}: could not parse PID from '{}'", + &env_id[..12.min(env_id.len())], + pid_str.trim() + ); + } + if let Some(p) = pid { + if Path::new(&format!("/proc/{p}")).exists() { + return Ok(RuntimeStatus { + env_id: env_id.to_owned(), + running: true, + pid: Some(p), + }); + } + let _ = std::fs::remove_file(&running_file); + } + } + + Ok(RuntimeStatus { + env_id: env_id.to_owned(), + running: false, + pid: None, + }) + } +} + +fn default_store_root() -> PathBuf { + if let Ok(home) = std::env::var("HOME") { + PathBuf::from(home).join(".local/share/karapace") + } else { + PathBuf::from("/tmp/karapace") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn oci_env_dir_layout() { + let dir = tempfile::tempdir().unwrap(); + let backend = OciBackend::with_store_root(dir.path()); + let env_dir = backend.env_dir("abc123"); + assert_eq!(env_dir, dir.path().join("env").join("abc123")); + } + + #[test] + fn oci_status_reports_not_running() { + let dir = tempfile::tempdir().unwrap(); + let backend = OciBackend::with_store_root(dir.path()); + let status = backend.status("oci-test").unwrap(); + assert!(!status.running); + } + + #[test] + fn oci_availability_check() { + let backend = OciBackend::new(); + // Just ensure this doesn't panic; result depends on host + let _ = backend.available(); + } +} diff --git a/crates/karapace-runtime/src/prereq.rs b/crates/karapace-runtime/src/prereq.rs new file mode 100644 index 0000000..f4b6437 --- /dev/null +++ b/crates/karapace-runtime/src/prereq.rs @@ -0,0 +1,151 @@ +use std::fmt; +use std::process::Command; + +/// A missing prerequisite with actionable install instructions. +#[derive(Debug)] +pub struct MissingPrereq { + pub name: &'static str, + pub purpose: &'static str, + pub install_hint: &'static str, +} + +impl fmt::Display for MissingPrereq { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + " - {}: {} (install: {})", + self.name, self.purpose, self.install_hint + ) + } +} + +fn command_exists(name: &str) -> bool { + Command::new("which") + .arg(name) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn user_namespaces_work() -> bool { + Command::new("unshare") + .args(["--user", "--map-root-user", "--fork", "true"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Check all prerequisites for the namespace backend. +/// Returns a list of missing items. Empty list means all prerequisites are met. +pub fn check_namespace_prereqs() -> Vec { + let mut missing = Vec::new(); + + if !command_exists("unshare") { + missing.push(MissingPrereq { + name: "unshare", + purpose: "user namespace isolation", + install_hint: "part of util-linux (usually pre-installed)", + }); + } else if !user_namespaces_work() { + missing.push(MissingPrereq { + name: "user namespaces", + purpose: "unprivileged container isolation", + install_hint: + "enable CONFIG_USER_NS=y in kernel, or: sysctl kernel.unprivileged_userns_clone=1", + }); + } + + if !command_exists("fuse-overlayfs") { + missing.push(MissingPrereq { + name: "fuse-overlayfs", + purpose: "overlay filesystem for writable container layers", + install_hint: "zypper install fuse-overlayfs | apt install fuse-overlayfs | dnf install fuse-overlayfs | pacman -S fuse-overlayfs", + }); + } + + if !command_exists("curl") { + missing.push(MissingPrereq { + name: "curl", + purpose: "downloading container images", + install_hint: + "zypper install curl | apt install curl | dnf install curl | pacman -S curl", + }); + } + + missing +} + +/// Check prerequisites for the OCI backend. +pub fn check_oci_prereqs() -> Vec { + let mut missing = Vec::new(); + + let has_runtime = command_exists("crun") || command_exists("runc") || command_exists("youki"); + + if !has_runtime { + missing.push(MissingPrereq { + name: "OCI runtime", + purpose: "OCI container execution", + install_hint: "install one of: crun, runc, or youki", + }); + } + + if !command_exists("curl") { + missing.push(MissingPrereq { + name: "curl", + purpose: "downloading container images", + install_hint: + "zypper install curl | apt install curl | dnf install curl | pacman -S curl", + }); + } + + missing +} + +/// Format a list of missing prerequisites into a user-friendly error message. +pub fn format_missing(missing: &[MissingPrereq]) -> String { + use std::fmt::Write as _; + let mut msg = String::from("missing prerequisites:\n"); + for m in missing { + let _ = writeln!(msg, "{m}"); + } + msg.push_str("\nKarapace requires these tools to create container environments."); + msg +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_prereq_display() { + let m = MissingPrereq { + name: "curl", + purpose: "downloading images", + install_hint: "apt install curl", + }; + let s = format!("{m}"); + assert!(s.contains("curl")); + assert!(s.contains("downloading images")); + assert!(s.contains("apt install curl")); + } + + #[test] + fn format_missing_produces_readable_output() { + let items = vec![ + MissingPrereq { + name: "curl", + purpose: "downloads", + install_hint: "apt install curl", + }, + MissingPrereq { + name: "fuse-overlayfs", + purpose: "overlay", + install_hint: "apt install fuse-overlayfs", + }, + ]; + let output = format_missing(&items); + assert!(output.contains("missing prerequisites:")); + assert!(output.contains("curl")); + assert!(output.contains("fuse-overlayfs")); + } +} diff --git a/crates/karapace-runtime/src/sandbox.rs b/crates/karapace-runtime/src/sandbox.rs new file mode 100644 index 0000000..e66759d --- /dev/null +++ b/crates/karapace-runtime/src/sandbox.rs @@ -0,0 +1,563 @@ +use crate::RuntimeError; +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Shell-escape a string for safe interpolation into shell scripts. +/// Wraps the value in single quotes, escaping any embedded single quotes. +fn shell_quote(s: &str) -> String { + // Single-quoting in POSIX shell: replace ' with '\'' then wrap in ' + format!("'{}'", s.replace('\'', "'\\''")) +} + +/// Shell-escape a Path for safe interpolation. +fn shell_quote_path(p: &Path) -> String { + shell_quote(&p.to_string_lossy()) +} + +#[derive(Debug, Clone)] +pub struct BindMount { + pub source: PathBuf, + pub target: PathBuf, + pub read_only: bool, +} + +#[derive(Debug, Clone)] +pub struct SandboxConfig { + pub rootfs: PathBuf, + pub overlay_lower: PathBuf, + pub overlay_upper: PathBuf, + pub overlay_work: PathBuf, + pub overlay_merged: PathBuf, + pub hostname: String, + pub bind_mounts: Vec, + pub env_vars: Vec<(String, String)>, + pub isolate_network: bool, + pub uid: u32, + pub gid: u32, + pub username: String, + pub home_dir: PathBuf, +} + +/// Safe wrapper around libc::getuid(). +#[allow(unsafe_code)] +fn current_uid() -> u32 { + // SAFETY: getuid() is always safe — no arguments, no side effects, cannot fail. + unsafe { libc::getuid() } +} + +/// Safe wrapper around libc::getgid(). +#[allow(unsafe_code)] +fn current_gid() -> u32 { + // SAFETY: getgid() is always safe — no arguments, no side effects, cannot fail. + unsafe { libc::getgid() } +} + +impl SandboxConfig { + pub fn new(rootfs: PathBuf, env_id: &str, env_dir: &Path) -> Self { + let uid = current_uid(); + let gid = current_gid(); + let username = std::env::var("USER").unwrap_or_else(|_| "user".to_owned()); + let home_dir = + PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| format!("/home/{username}"))); + + Self { + rootfs, + overlay_lower: env_dir.join("lower"), + overlay_upper: env_dir.join("upper"), + overlay_work: env_dir.join("work"), + overlay_merged: env_dir.join("merged"), + hostname: format!("karapace-{}", &env_id[..12.min(env_id.len())]), + bind_mounts: Vec::new(), + env_vars: Vec::new(), + isolate_network: false, + uid, + gid, + username, + home_dir, + } + } +} + +pub fn mount_overlay(config: &SandboxConfig) -> Result<(), RuntimeError> { + // Unmount any stale overlay from a previous failed run + let _ = unmount_overlay(config); + + // Clean stale work dir (fuse-overlayfs requires a clean workdir) + if config.overlay_work.exists() { + let _ = std::fs::remove_dir_all(&config.overlay_work); + } + + for dir in [ + &config.overlay_upper, + &config.overlay_work, + &config.overlay_merged, + ] { + std::fs::create_dir_all(dir)?; + } + + // Create a symlink to rootfs as lower dir if needed + if !config.overlay_lower.exists() { + #[cfg(unix)] + std::os::unix::fs::symlink(&config.rootfs, &config.overlay_lower)?; + } + + let status = Command::new("fuse-overlayfs") + .args([ + "-o", + &format!( + "lowerdir={},upperdir={},workdir={}", + config.rootfs.display(), + config.overlay_upper.display(), + config.overlay_work.display() + ), + &config.overlay_merged.to_string_lossy(), + ]) + .status() + .map_err(|e| { + RuntimeError::ExecFailed(format!( + "fuse-overlayfs not found or failed to start: {e}. Install with: sudo zypper install fuse-overlayfs" + )) + })?; + + if !status.success() { + return Err(RuntimeError::ExecFailed( + "fuse-overlayfs mount failed".to_owned(), + )); + } + + Ok(()) +} + +/// Check if a path is currently a mount point by inspecting /proc/mounts. +fn is_mounted(path: &Path) -> bool { + let canonical = match std::fs::canonicalize(path) { + Ok(p) => p.to_string_lossy().to_string(), + Err(_) => path.to_string_lossy().to_string(), + }; + match std::fs::read_to_string("/proc/mounts") { + Ok(mounts) => mounts + .lines() + .any(|line| line.split_whitespace().nth(1) == Some(&canonical)), + Err(_) => false, + } +} + +pub fn unmount_overlay(config: &SandboxConfig) -> Result<(), RuntimeError> { + if !config.overlay_merged.exists() { + return Ok(()); + } + // Only attempt unmount if actually mounted (avoids spurious errors) + if !is_mounted(&config.overlay_merged) { + return Ok(()); + } + let _ = Command::new("fusermount3") + .args(["-u", &config.overlay_merged.to_string_lossy()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + // Fallback if fusermount3 is not available + if is_mounted(&config.overlay_merged) { + let _ = Command::new("fusermount") + .args(["-u", &config.overlay_merged.to_string_lossy()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + } + Ok(()) +} + +pub fn setup_container_rootfs(config: &SandboxConfig) -> Result { + let merged = &config.overlay_merged; + + // Essential directories inside the container + for subdir in [ + "proc", "sys", "dev", "dev/pts", "dev/shm", "tmp", "run", "run/user", "etc", "var", + "var/tmp", + ] { + std::fs::create_dir_all(merged.join(subdir))?; + } + + // Create run/user/ for XDG_RUNTIME_DIR + let user_run = merged.join(format!("run/user/{}", config.uid)); + std::fs::create_dir_all(&user_run)?; + + // Create home directory + let container_home = merged.join( + config + .home_dir + .strip_prefix("/") + .unwrap_or(&config.home_dir), + ); + std::fs::create_dir_all(&container_home)?; + + // Write /etc/hostname + let _ = std::fs::write(merged.join("etc/hostname"), &config.hostname); + + // Ensure /etc/resolv.conf exists (copy from host for DNS) + if !merged.join("etc/resolv.conf").exists() && Path::new("/etc/resolv.conf").exists() { + let _ = std::fs::copy("/etc/resolv.conf", merged.join("etc/resolv.conf")); + } + + // Ensure user exists in /etc/passwd + ensure_user_in_container(config, merged)?; + + Ok(merged.clone()) +} + +fn ensure_user_in_container(config: &SandboxConfig, merged: &Path) -> Result<(), RuntimeError> { + let passwd_path = merged.join("etc/passwd"); + let existing = std::fs::read_to_string(&passwd_path).unwrap_or_default(); + + let user_entry = format!( + "{}:x:{}:{}::/{}:/bin/bash\n", + config.username, + config.uid, + config.gid, + config + .home_dir + .strip_prefix("/") + .unwrap_or(&config.home_dir) + .display() + ); + + if !existing.contains(&format!("{}:", config.username)) { + let mut content = existing; + if !content.contains("root:") { + content.push_str("root:x:0:0:root:/root:/bin/bash\n"); + } + content.push_str(&user_entry); + std::fs::write(&passwd_path, content)?; + } + + // Ensure group exists + let group_path = merged.join("etc/group"); + let existing_groups = std::fs::read_to_string(&group_path).unwrap_or_default(); + let group_entry = format!("{}:x:{}:\n", config.username, config.gid); + if !existing_groups.contains(&format!("{}:", config.username)) { + let mut content = existing_groups; + if !content.contains("root:") { + content.push_str("root:x:0:\n"); + } + content.push_str(&group_entry); + std::fs::write(&group_path, content)?; + } + + Ok(()) +} + +fn build_unshare_command(config: &SandboxConfig) -> Command { + let mut cmd = Command::new("unshare"); + cmd.args(["--user", "--map-root-user", "--mount", "--pid", "--fork"]); + + if config.isolate_network { + cmd.arg("--net"); + } + + cmd +} + +fn build_setup_script(config: &SandboxConfig) -> String { + let merged = &config.overlay_merged; + let qm = shell_quote_path(merged); + let mut script = String::new(); + + // Mount /proc + let _ = writeln!(script, "mount -t proc proc {qm}/proc 2>/dev/null || true"); + + // Bind mount /sys (read-only) + let _ = writeln!(script, "mount --rbind /sys {qm}/sys 2>/dev/null && mount --make-rslave {qm}/sys 2>/dev/null || true"); + + // Bind mount /dev + let _ = writeln!(script, "mount --rbind /dev {qm}/dev 2>/dev/null && mount --make-rslave {qm}/dev 2>/dev/null || true"); + + // Bind mount home directory + let container_home = merged.join( + config + .home_dir + .strip_prefix("/") + .unwrap_or(&config.home_dir), + ); + let _ = writeln!( + script, + "mount --bind {} {} 2>/dev/null || true", + shell_quote_path(&config.home_dir), + shell_quote_path(&container_home) + ); + + // Bind mount /etc/resolv.conf for DNS resolution + let _ = writeln!(script, "touch {qm}/etc/resolv.conf 2>/dev/null; mount --bind /etc/resolv.conf {qm}/etc/resolv.conf 2>/dev/null || true"); + + // Bind mount /tmp + let _ = writeln!(script, "mount --bind /tmp {qm}/tmp 2>/dev/null || true"); + + // Bind mounts from config (user-supplied paths — must be quoted) + for bm in &config.bind_mounts { + let target = if bm.target.is_absolute() { + merged.join(bm.target.strip_prefix("/").unwrap_or(&bm.target)) + } else { + merged.join(&bm.target) + }; + let qt = shell_quote_path(&target); + let qs = shell_quote_path(&bm.source); + let _ = writeln!( + script, + "mkdir -p {qt} 2>/dev/null; mount --bind {qs} {qt} 2>/dev/null || true" + ); + if bm.read_only { + let _ = writeln!(script, "mount -o remount,ro,bind {qt} 2>/dev/null || true"); + } + } + + // Bind mount XDG_RUNTIME_DIR sockets (Wayland, PipeWire, D-Bus) + if let Ok(xdg_run) = std::env::var("XDG_RUNTIME_DIR") { + let container_run = merged.join(format!("run/user/{}", config.uid)); + for socket in &["wayland-0", "pipewire-0", "pulse/native", "bus"] { + let src = PathBuf::from(&xdg_run).join(socket); + if src.exists() { + let dst = container_run.join(socket); + let qs = shell_quote_path(&src); + let qd = shell_quote_path(&dst); + if let Some(parent) = dst.parent() { + let _ = writeln!( + script, + "mkdir -p {} 2>/dev/null || true", + shell_quote_path(parent) + ); + } + // For sockets, touch the target first + if src.is_file() || !src.is_dir() { + let _ = writeln!(script, "touch {qd} 2>/dev/null || true"); + } + let _ = writeln!(script, "mount --bind {qs} {qd} 2>/dev/null || true"); + } + } + } + + // Bind mount X11 socket if present + if Path::new("/tmp/.X11-unix").exists() { + let _ = writeln!( + script, + "mount --bind /tmp/.X11-unix {qm}/tmp/.X11-unix 2>/dev/null || true" + ); + } + + // Chroot and exec + let _ = write!(script, "exec chroot {qm} /bin/sh -c '"); + + script +} + +pub fn enter_interactive(config: &SandboxConfig) -> Result { + let merged = &config.overlay_merged; + + let mut setup = build_setup_script(config); + + // Build environment variable exports (all values shell-quoted, keys validated) + let mut env_exports = String::new(); + for (key, val) in &config.env_vars { + if !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') { + continue; // Skip keys with unsafe characters + } + let _ = write!(env_exports, "export {}={}; ", key, shell_quote(val)); + } + + // Set standard env vars (all values shell-quoted) + let _ = write!( + env_exports, + "export HOME={}; ", + shell_quote_path(&config.home_dir) + ); + let _ = write!( + env_exports, + "export USER={}; ", + shell_quote(&config.username) + ); + let _ = write!( + env_exports, + "export HOSTNAME={}; ", + shell_quote(&config.hostname) + ); + if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") { + let _ = write!( + env_exports, + "export XDG_RUNTIME_DIR={}; ", + shell_quote(&xdg) + ); + } + if let Ok(display) = std::env::var("DISPLAY") { + let _ = write!(env_exports, "export DISPLAY={}; ", shell_quote(&display)); + } + if let Ok(wayland) = std::env::var("WAYLAND_DISPLAY") { + let _ = write!( + env_exports, + "export WAYLAND_DISPLAY={}; ", + shell_quote(&wayland) + ); + } + env_exports.push_str("export TERM=${TERM:-xterm-256color}; "); + let _ = write!( + env_exports, + "export KARAPACE_ENV=1; export KARAPACE_HOSTNAME={}; ", + shell_quote(&config.hostname) + ); + + // Determine shell + let shell = if merged.join("bin/bash").exists() || merged.join("usr/bin/bash").exists() { + "/bin/bash" + } else { + "/bin/sh" + }; + + let _ = write!(setup, "{env_exports}cd ~; exec {shell} -l'"); + + let mut cmd = build_unshare_command(config); + cmd.arg("/bin/sh").arg("-c").arg(&setup); + + // Pass through stdin/stdout/stderr for interactive use + cmd.stdin(std::process::Stdio::inherit()); + cmd.stdout(std::process::Stdio::inherit()); + cmd.stderr(std::process::Stdio::inherit()); + + let status = cmd + .status() + .map_err(|e| RuntimeError::ExecFailed(format!("failed to enter sandbox: {e}")))?; + + Ok(status.code().unwrap_or(1)) +} + +pub fn exec_in_container( + config: &SandboxConfig, + command: &[String], +) -> Result { + let mut setup = build_setup_script(config); + + // Environment (all values shell-quoted, keys validated) + let mut env_exports = String::new(); + for (key, val) in &config.env_vars { + if !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') { + continue; // Skip keys with unsafe characters + } + let _ = write!(env_exports, "export {}={}; ", key, shell_quote(val)); + } + let _ = write!( + env_exports, + "export HOME={}; ", + shell_quote_path(&config.home_dir) + ); + let _ = write!( + env_exports, + "export USER={}; ", + shell_quote(&config.username) + ); + env_exports.push_str("export KARAPACE_ENV=1; "); + + let escaped_cmd: Vec = command.iter().map(|a| shell_quote(a)).collect(); + let _ = write!(setup, "{env_exports}{}'", escaped_cmd.join(" ")); + + let mut cmd = build_unshare_command(config); + cmd.arg("/bin/sh").arg("-c").arg(&setup); + + cmd.output() + .map_err(|e| RuntimeError::ExecFailed(format!("exec in container failed: {e}"))) +} + +pub fn install_packages_in_container( + config: &SandboxConfig, + install_cmd: &[String], +) -> Result<(), RuntimeError> { + if install_cmd.is_empty() { + return Ok(()); + } + + let output = exec_in_container(config, install_cmd)?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(RuntimeError::ExecFailed(format!( + "package installation failed:\nstdout: {stdout}\nstderr: {stderr}" + ))); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sandbox_config_defaults() { + let dir = tempfile::tempdir().unwrap(); + let rootfs = dir.path().join("rootfs"); + std::fs::create_dir_all(&rootfs).unwrap(); + let config = SandboxConfig::new(rootfs, "abc123def456", dir.path()); + assert!(config.hostname.starts_with("karapace-")); + assert!(!config.isolate_network); + } + + #[test] + fn shell_quote_escapes_single_quotes() { + assert_eq!(shell_quote("hello"), "'hello'"); + assert_eq!(shell_quote("it's"), "'it'\\''s'"); + assert_eq!(shell_quote(""), "''"); + } + + #[test] + fn shell_quote_prevents_injection() { + // Command substitution is safely wrapped in single quotes + let malicious = "$(rm -rf /)"; + let quoted = shell_quote(malicious); + assert_eq!(quoted, "'$(rm -rf /)'"); + assert!(quoted.starts_with('\'') && quoted.ends_with('\'')); + + // Backtick injection is also safely quoted + let backtick = "`whoami`"; + let quoted = shell_quote(backtick); + assert_eq!(quoted, "'`whoami`'"); + + // Newline injection + let newline = "value\n; rm -rf /"; + let quoted = shell_quote(newline); + assert!(quoted.starts_with('\'') && quoted.ends_with('\'')); + } + + #[test] + fn shell_quote_path_handles_spaces() { + let p = PathBuf::from("/home/user/my project/dir"); + let quoted = shell_quote_path(&p); + assert_eq!(quoted, "'/home/user/my project/dir'"); + } + + #[test] + fn build_setup_script_contains_essential_mounts() { + let dir = tempfile::tempdir().unwrap(); + let rootfs = dir.path().join("rootfs"); + std::fs::create_dir_all(&rootfs).unwrap(); + let config = SandboxConfig::new(rootfs, "abc123def456", dir.path()); + let script = build_setup_script(&config); + assert!(script.contains("mount -t proc")); + assert!(script.contains("mount --rbind /sys")); + assert!(script.contains("mount --rbind /dev")); + assert!(script.contains("chroot")); + } + + #[test] + fn is_mounted_returns_false_for_regular_dir() { + let dir = tempfile::tempdir().unwrap(); + assert!(!is_mounted(dir.path())); + } + + #[test] + fn unmount_overlay_noop_on_non_mounted() { + let dir = tempfile::tempdir().unwrap(); + let rootfs = dir.path().join("rootfs"); + std::fs::create_dir_all(&rootfs).unwrap(); + let config = SandboxConfig::new(rootfs, "abc123def456", dir.path()); + // Create the merged dir but don't mount anything + std::fs::create_dir_all(&config.overlay_merged).unwrap(); + // Should not error — just returns Ok because nothing is mounted + assert!(unmount_overlay(&config).is_ok()); + } +} diff --git a/crates/karapace-runtime/src/security.rs b/crates/karapace-runtime/src/security.rs new file mode 100644 index 0000000..18f19c7 --- /dev/null +++ b/crates/karapace-runtime/src/security.rs @@ -0,0 +1,388 @@ +use crate::RuntimeError; +use karapace_schema::NormalizedManifest; +use serde::{Deserialize, Serialize}; + +/// Resolve `.` and `..` components in an absolute path without touching the filesystem. +/// +/// This is critical for security: we must not rely on `std::fs::canonicalize()` +/// because the path may not exist yet, and we need deterministic behavior. +fn canonicalize_logical(path: &str) -> String { + let mut parts: Vec<&str> = Vec::new(); + for component in path.split('/') { + match component { + "" | "." => {} + ".." => { + parts.pop(); + } + other => parts.push(other), + } + } + format!("/{}", parts.join("/")) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SecurityPolicy { + pub allowed_mount_prefixes: Vec, + pub allowed_devices: Vec, + pub allow_network: bool, + pub allow_gpu: bool, + pub allow_audio: bool, + pub allowed_env_vars: Vec, + pub denied_env_vars: Vec, + pub max_cpu_shares: Option, + pub max_memory_mb: Option, +} + +impl Default for SecurityPolicy { + fn default() -> Self { + Self { + allowed_mount_prefixes: vec!["/home".to_owned(), "/tmp".to_owned()], + allowed_devices: Vec::new(), + allow_network: false, + allow_gpu: false, + allow_audio: false, + allowed_env_vars: vec![ + "TERM".to_owned(), + "LANG".to_owned(), + "HOME".to_owned(), + "USER".to_owned(), + "PATH".to_owned(), + "SHELL".to_owned(), + "XDG_RUNTIME_DIR".to_owned(), + ], + denied_env_vars: vec![ + "SSH_AUTH_SOCK".to_owned(), + "GPG_AGENT_INFO".to_owned(), + "AWS_SECRET_ACCESS_KEY".to_owned(), + "DOCKER_HOST".to_owned(), + ], + max_cpu_shares: None, + max_memory_mb: None, + } + } +} + +impl SecurityPolicy { + pub fn from_manifest(manifest: &NormalizedManifest) -> Self { + let mut allowed_devices = Vec::new(); + if manifest.hardware_gpu { + allowed_devices.push("/dev/dri".to_owned()); + } + if manifest.hardware_audio { + allowed_devices.push("/dev/snd".to_owned()); + } + + Self { + allow_gpu: manifest.hardware_gpu, + allow_audio: manifest.hardware_audio, + allow_network: !manifest.network_isolation, + allowed_devices, + max_cpu_shares: manifest.cpu_shares, + max_memory_mb: manifest.memory_limit_mb, + ..Self::default() + } + } + + pub fn validate_mounts(&self, manifest: &NormalizedManifest) -> Result<(), RuntimeError> { + for mount in &manifest.mounts { + let host = &mount.host_path; + if host.starts_with('/') { + let canonical = canonicalize_logical(host); + let allowed = self + .allowed_mount_prefixes + .iter() + .any(|prefix| canonical.starts_with(prefix)); + if !allowed { + return Err(RuntimeError::MountDenied(format!( + "mount '{host}' (resolved: {canonical}) is not under any allowed prefix: {:?}", + self.allowed_mount_prefixes + ))); + } + } + } + Ok(()) + } + + pub fn validate_devices(&self, manifest: &NormalizedManifest) -> Result<(), RuntimeError> { + if manifest.hardware_gpu && !self.allow_gpu { + return Err(RuntimeError::DeviceDenied( + "GPU access requested but not allowed by policy".to_owned(), + )); + } + if manifest.hardware_audio && !self.allow_audio { + return Err(RuntimeError::DeviceDenied( + "audio access requested but not allowed by policy".to_owned(), + )); + } + Ok(()) + } + + pub fn filter_env_vars(&self) -> Vec<(String, String)> { + let mut result = Vec::new(); + for key in &self.allowed_env_vars { + if self.denied_env_vars.contains(key) { + continue; + } + if let Ok(val) = std::env::var(key) { + result.push((key.clone(), val)); + } + } + result + } + + pub fn validate_resource_limits( + &self, + manifest: &NormalizedManifest, + ) -> Result<(), RuntimeError> { + if let (Some(req), Some(max)) = (manifest.cpu_shares, self.max_cpu_shares) { + if req > max { + return Err(RuntimeError::PolicyViolation(format!( + "requested CPU shares {req} exceeds policy max {max}" + ))); + } + } + if let (Some(req), Some(max)) = (manifest.memory_limit_mb, self.max_memory_mb) { + if req > max { + return Err(RuntimeError::PolicyViolation(format!( + "requested memory {req}MB exceeds policy max {max}MB" + ))); + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use karapace_schema::parse_manifest_str; + + #[test] + fn default_policy_denies_gpu() { + let manifest = parse_manifest_str( + r#" +manifest_version = 1 +[base] +image = "rolling" +[hardware] +gpu = true +"#, + ) + .unwrap() + .normalize() + .unwrap(); + + let policy = SecurityPolicy::default(); + assert!(policy.validate_devices(&manifest).is_err()); + } + + #[test] + fn manifest_derived_policy_allows_declared_hardware() { + let manifest = parse_manifest_str( + r#" +manifest_version = 1 +[base] +image = "rolling" +[hardware] +gpu = true +audio = true +"#, + ) + .unwrap() + .normalize() + .unwrap(); + + let policy = SecurityPolicy::from_manifest(&manifest); + assert!(policy.validate_devices(&manifest).is_ok()); + assert!(policy.allow_gpu); + assert!(policy.allow_audio); + assert!(policy.allowed_devices.contains(&"/dev/dri".to_owned())); + } + + #[test] + fn absolute_mounts_checked_against_whitelist() { + let manifest = parse_manifest_str( + r#" +manifest_version = 1 +[base] +image = "rolling" +[mounts] +bad = "/etc/shadow:/secrets" +"#, + ) + .unwrap() + .normalize() + .unwrap(); + + let policy = SecurityPolicy::default(); + assert!(policy.validate_mounts(&manifest).is_err()); + } + + #[test] + fn denied_env_vars_are_filtered() { + let policy = SecurityPolicy::default(); + assert!(policy.denied_env_vars.contains(&"SSH_AUTH_SOCK".to_owned())); + assert!(policy + .denied_env_vars + .contains(&"AWS_SECRET_ACCESS_KEY".to_owned())); + let filtered = policy.filter_env_vars(); + assert!(filtered + .iter() + .all(|(k, _)| !policy.denied_env_vars.contains(k))); + } + + #[test] + fn resource_limits_enforced() { + let manifest = parse_manifest_str( + r#" +manifest_version = 1 +[base] +image = "rolling" +[runtime] +backend = "namespace" +[runtime.resource_limits] +cpu_shares = 2048 +memory_limit_mb = 8192 +"#, + ) + .unwrap() + .normalize() + .unwrap(); + + let mut policy = SecurityPolicy::from_manifest(&manifest); + assert!(policy.validate_resource_limits(&manifest).is_ok()); + + policy.max_cpu_shares = Some(1024); + assert!(policy.validate_resource_limits(&manifest).is_err()); + } + + #[test] + fn relative_mounts_always_allowed() { + let manifest = parse_manifest_str( + r#" +manifest_version = 1 +[base] +image = "rolling" +[mounts] +workspace = "./:/workspace" +"#, + ) + .unwrap() + .normalize() + .unwrap(); + + let policy = SecurityPolicy::default(); + assert!(policy.validate_mounts(&manifest).is_ok()); + } + + #[test] + fn path_traversal_via_dotdot_is_rejected() { + let manifest = parse_manifest_str( + r#" +manifest_version = 1 +[base] +image = "rolling" +[mounts] +bad = "/../etc/shadow:/secrets" +"#, + ) + .unwrap() + .normalize() + .unwrap(); + + let policy = SecurityPolicy::default(); + assert!( + policy.validate_mounts(&manifest).is_err(), + "path traversal via /../ must be rejected" + ); + } + + #[test] + fn path_traversal_via_allowed_prefix_breakout_is_rejected() { + let manifest = parse_manifest_str( + r#" +manifest_version = 1 +[base] +image = "rolling" +[mounts] +bad = "/home/../etc/passwd:/data" +"#, + ) + .unwrap() + .normalize() + .unwrap(); + + let policy = SecurityPolicy::default(); + // /home/../etc/passwd canonicalizes to /etc/passwd, which is NOT under /home + assert!( + policy.validate_mounts(&manifest).is_err(), + "/home/../etc/passwd must be rejected (resolves to /etc/passwd)" + ); + } + + #[test] + fn root_path_is_rejected() { + let manifest = parse_manifest_str( + r#" +manifest_version = 1 +[base] +image = "rolling" +[mounts] +bad = "/:/rootfs" +"#, + ) + .unwrap() + .normalize() + .unwrap(); + + let policy = SecurityPolicy::default(); + assert!( + policy.validate_mounts(&manifest).is_err(), + "mounting / must be rejected" + ); + } + + #[test] + fn etc_shadow_is_rejected() { + let manifest = parse_manifest_str( + r#" +manifest_version = 1 +[base] +image = "rolling" +[mounts] +bad = "/etc/shadow:/shadow" +"#, + ) + .unwrap() + .normalize() + .unwrap(); + + let policy = SecurityPolicy::default(); + assert!( + policy.validate_mounts(&manifest).is_err(), + "/etc/shadow must be rejected" + ); + } + + #[test] + fn proc_is_rejected() { + let manifest = parse_manifest_str( + r#" +manifest_version = 1 +[base] +image = "rolling" +[mounts] +bad = "/proc/self/root:/escape" +"#, + ) + .unwrap() + .normalize() + .unwrap(); + + let policy = SecurityPolicy::default(); + assert!( + policy.validate_mounts(&manifest).is_err(), + "/proc must be rejected" + ); + } +} diff --git a/crates/karapace-runtime/src/terminal.rs b/crates/karapace-runtime/src/terminal.rs new file mode 100644 index 0000000..bfc5c49 --- /dev/null +++ b/crates/karapace-runtime/src/terminal.rs @@ -0,0 +1,54 @@ +use std::io::Write; + +const OSC_START: &str = "\x1b]777;"; +const OSC_END: &str = "\x1b\\"; + +pub fn emit_container_push(env_id: &str, hostname: &str) { + if is_interactive_terminal() { + let marker = format!("{OSC_START}container;push;{hostname};karapace;{env_id}{OSC_END}"); + let _ = std::io::stderr().write_all(marker.as_bytes()); + let _ = std::io::stderr().flush(); + } +} + +pub fn emit_container_pop() { + if is_interactive_terminal() { + let marker = format!("{OSC_START}container;pop;;{OSC_END}"); + let _ = std::io::stderr().write_all(marker.as_bytes()); + let _ = std::io::stderr().flush(); + } +} + +pub fn print_container_banner(env_id: &str, image: &str, hostname: &str) { + if is_interactive_terminal() { + let short_id = &env_id[..12.min(env_id.len())]; + eprintln!( + "\x1b[1;36m[karapace]\x1b[0m entering \x1b[1m{image}\x1b[0m ({short_id}) as \x1b[1m{hostname}\x1b[0m" + ); + } +} + +pub fn print_container_exit(env_id: &str) { + if is_interactive_terminal() { + let short_id = &env_id[..12.min(env_id.len())]; + eprintln!("\x1b[1;36m[karapace]\x1b[0m exited environment {short_id}"); + } +} + +#[allow(unsafe_code)] +fn is_interactive_terminal() -> bool { + // SAFETY: isatty() is always safe — checks if fd is a terminal, no side effects. + unsafe { libc::isatty(libc::STDERR_FILENO) != 0 } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn osc_markers_dont_panic() { + // Just ensure these don't crash; output depends on terminal + emit_container_push("abc123def456", "karapace-abc123def456"); + emit_container_pop(); + } +}