mirror of
https://github.com/marcoallegretti/karapace.git
synced 2026-03-26 21:43:09 +00:00
feat: karapace-schema — manifest v1, normalization, identity hashing, lock file v2
- TOML manifest parsing with strict schema validation (deny_unknown_fields) - Deterministic normalization: sorted packages, deduplication, canonical JSON - Two-phase identity: preliminary (from manifest) and canonical (from lock) - Lock file v2: resolved packages with pinned versions, base image content digest - Dual lock verification: integrity (hash) and manifest intent (drift detection) - Built-in presets: dev, dev-rust, dev-python, gui-app, gaming, minimal - Blake3 256-bit hashing throughout
This commit is contained in:
parent
78d40c0d0a
commit
cdd13755a0
9 changed files with 3056 additions and 0 deletions
18
crates/karapace-schema/Cargo.toml
Normal file
18
crates/karapace-schema/Cargo.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
[package]
|
||||||
|
name = "karapace-schema"
|
||||||
|
description = "Manifest parsing, normalization, identity hashing, and lock file 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
|
||||||
|
toml.workspace = true
|
||||||
|
blake3.workspace = true
|
||||||
|
tempfile.workspace = true
|
||||||
1229
crates/karapace-schema/karapace-schema.cdx.json
Normal file
1229
crates/karapace-schema/karapace-schema.cdx.json
Normal file
File diff suppressed because it is too large
Load diff
195
crates/karapace-schema/src/identity.rs
Normal file
195
crates/karapace-schema/src/identity.rs
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
use crate::normalize::NormalizedManifest;
|
||||||
|
use crate::types::{EnvId, ShortId};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// Deterministic identity for an environment, derived from its manifest content.
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
|
pub struct EnvIdentity {
|
||||||
|
pub env_id: EnvId,
|
||||||
|
pub short_id: ShortId,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute a **preliminary** environment identity from unresolved manifest data.
|
||||||
|
///
|
||||||
|
/// This is NOT the canonical identity. The canonical identity is computed by
|
||||||
|
/// [`LockFile::compute_identity()`] after dependency resolution, which uses:
|
||||||
|
/// - Actual base image content digest (not tag name hash)
|
||||||
|
/// - Resolved package versions (not just package names)
|
||||||
|
/// - Full hardware/mount/runtime policy
|
||||||
|
///
|
||||||
|
/// This function is used only for:
|
||||||
|
/// - The `init` command (before resolution has occurred)
|
||||||
|
/// - Internal lookup during rebuild (to find old environments)
|
||||||
|
///
|
||||||
|
/// After `build`, the env_id stored in metadata comes from the lock file.
|
||||||
|
pub fn compute_env_id(normalized: &NormalizedManifest) -> EnvIdentity {
|
||||||
|
let mut hasher = blake3::Hasher::new();
|
||||||
|
|
||||||
|
hasher.update(normalized.canonical_json().as_bytes());
|
||||||
|
|
||||||
|
let base_digest = blake3::hash(normalized.base_image.as_bytes())
|
||||||
|
.to_hex()
|
||||||
|
.to_string();
|
||||||
|
hasher.update(base_digest.as_bytes());
|
||||||
|
|
||||||
|
for pkg in &normalized.system_packages {
|
||||||
|
hasher.update(format!("pkg:{pkg}").as_bytes());
|
||||||
|
}
|
||||||
|
for app in &normalized.gui_apps {
|
||||||
|
hasher.update(format!("app:{app}").as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalized.hardware_gpu {
|
||||||
|
hasher.update(b"hw:gpu");
|
||||||
|
}
|
||||||
|
if normalized.hardware_audio {
|
||||||
|
hasher.update(b"hw:audio");
|
||||||
|
}
|
||||||
|
|
||||||
|
for mount in &normalized.mounts {
|
||||||
|
hasher.update(
|
||||||
|
format!(
|
||||||
|
"mount:{}:{}:{}",
|
||||||
|
mount.label, mount.host_path, mount.container_path
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasher.update(format!("backend:{}", normalized.runtime_backend).as_bytes());
|
||||||
|
|
||||||
|
if normalized.network_isolation {
|
||||||
|
hasher.update(b"net:isolated");
|
||||||
|
}
|
||||||
|
if let Some(cpu) = normalized.cpu_shares {
|
||||||
|
hasher.update(format!("cpu:{cpu}").as_bytes());
|
||||||
|
}
|
||||||
|
if let Some(mem) = normalized.memory_limit_mb {
|
||||||
|
hasher.update(format!("mem:{mem}").as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
let hex = hasher.finalize().to_hex().to_string();
|
||||||
|
let short = hex[..12].to_owned();
|
||||||
|
|
||||||
|
EnvIdentity {
|
||||||
|
env_id: EnvId::new(hex),
|
||||||
|
short_id: ShortId::new(short),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::manifest::parse_manifest_str;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stable_id_for_equivalent_manifests() {
|
||||||
|
let a = parse_manifest_str(
|
||||||
|
r#"
|
||||||
|
manifest_version = 1
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
[system]
|
||||||
|
packages = ["git", "clang"]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.normalize()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let b = parse_manifest_str(
|
||||||
|
r#"
|
||||||
|
manifest_version = 1
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
[system]
|
||||||
|
packages = ["clang", "git"]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.normalize()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(compute_env_id(&a), compute_env_id(&b));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_inputs_produce_different_ids() {
|
||||||
|
let a = parse_manifest_str(
|
||||||
|
r#"
|
||||||
|
manifest_version = 1
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
[system]
|
||||||
|
packages = ["git"]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.normalize()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let b = parse_manifest_str(
|
||||||
|
r#"
|
||||||
|
manifest_version = 1
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
[system]
|
||||||
|
packages = ["git", "cmake"]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.normalize()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_ne!(compute_env_id(&a), compute_env_id(&b));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backend_change_changes_id() {
|
||||||
|
let a = parse_manifest_str(
|
||||||
|
r#"
|
||||||
|
manifest_version = 1
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
[runtime]
|
||||||
|
backend = "namespace"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.normalize()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let b = parse_manifest_str(
|
||||||
|
r#"
|
||||||
|
manifest_version = 1
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
[runtime]
|
||||||
|
backend = "oci"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.normalize()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_ne!(compute_env_id(&a), compute_env_id(&b));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_id_is_12_chars() {
|
||||||
|
let n = parse_manifest_str(
|
||||||
|
r#"
|
||||||
|
manifest_version = 1
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.normalize()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let id = compute_env_id(&n);
|
||||||
|
assert_eq!(id.short_id.as_str().len(), 12);
|
||||||
|
assert!(id.env_id.as_str().starts_with(id.short_id.as_str()));
|
||||||
|
}
|
||||||
|
}
|
||||||
23
crates/karapace-schema/src/lib.rs
Normal file
23
crates/karapace-schema/src/lib.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
//! Manifest parsing, normalization, lock files, and environment identity for Karapace.
|
||||||
|
//!
|
||||||
|
//! This crate defines the schema layer: TOML manifest parsing (`ManifestV1`),
|
||||||
|
//! normalized representations (`NormalizedManifest`), deterministic environment
|
||||||
|
//! identity computation (`compute_env_id`), lock file generation/verification
|
||||||
|
//! (`LockFile`), and built-in preset definitions.
|
||||||
|
|
||||||
|
pub mod identity;
|
||||||
|
pub mod lock;
|
||||||
|
pub mod manifest;
|
||||||
|
pub mod normalize;
|
||||||
|
pub mod preset;
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
pub use identity::{compute_env_id, EnvIdentity};
|
||||||
|
pub use lock::{LockError, LockFile, ResolutionResult, ResolvedPackage};
|
||||||
|
pub use manifest::{
|
||||||
|
parse_manifest_file, parse_manifest_str, BaseSection, GuiSection, HardwareSection,
|
||||||
|
ManifestError, ManifestV1, MountsSection, ResourceLimits, RuntimeSection, SystemSection,
|
||||||
|
};
|
||||||
|
pub use normalize::{NormalizedManifest, NormalizedMount};
|
||||||
|
pub use preset::{get_preset, list_presets, Preset, BUILTIN_PRESETS};
|
||||||
|
pub use types::{EnvId, LayerHash, ObjectHash, ShortId};
|
||||||
874
crates/karapace-schema/src/lock.rs
Normal file
874
crates/karapace-schema/src/lock.rs
Normal file
|
|
@ -0,0 +1,874 @@
|
||||||
|
use crate::identity::EnvIdentity;
|
||||||
|
use crate::manifest::ManifestError;
|
||||||
|
use crate::normalize::{NormalizedManifest, NormalizedMount};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum LockError {
|
||||||
|
#[error("manifest error: {0}")]
|
||||||
|
Manifest(#[from] ManifestError),
|
||||||
|
#[error("lock file I/O error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("lock file parse error: {0}")]
|
||||||
|
Parse(#[from] toml::de::Error),
|
||||||
|
#[error("lock file serialize error: {0}")]
|
||||||
|
Serialize(#[from] toml::ser::Error),
|
||||||
|
#[error("lock file env_id mismatch: lock has '{lock_id}', recomputed '{computed_id}'")]
|
||||||
|
EnvIdMismatch {
|
||||||
|
lock_id: String,
|
||||||
|
computed_id: String,
|
||||||
|
},
|
||||||
|
#[error("lock file manifest drift: {0}")]
|
||||||
|
ManifestDrift(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A resolved package with pinned version.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub struct ResolvedPackage {
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of dependency resolution against a base image.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ResolutionResult {
|
||||||
|
/// Content hash (blake3) of the base image rootfs tarball.
|
||||||
|
pub base_image_digest: String,
|
||||||
|
/// Resolved packages with pinned versions.
|
||||||
|
pub resolved_packages: Vec<ResolvedPackage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The lock file captures the fully resolved state of an environment.
|
||||||
|
///
|
||||||
|
/// The env_id is computed deterministically from the locked fields,
|
||||||
|
/// not from unresolved manifest data. This guarantees:
|
||||||
|
/// same lockfile → same env_id → same environment.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct LockFile {
|
||||||
|
pub lock_version: u32,
|
||||||
|
pub env_id: String,
|
||||||
|
pub short_id: String,
|
||||||
|
|
||||||
|
// Base image identity
|
||||||
|
pub base_image: String,
|
||||||
|
pub base_image_digest: String,
|
||||||
|
|
||||||
|
// Resolved dependencies (version-pinned)
|
||||||
|
pub resolved_packages: Vec<ResolvedPackage>,
|
||||||
|
pub resolved_apps: Vec<String>,
|
||||||
|
|
||||||
|
// Runtime policy (included in hash contract)
|
||||||
|
pub runtime_backend: String,
|
||||||
|
pub hardware_gpu: bool,
|
||||||
|
pub hardware_audio: bool,
|
||||||
|
pub network_isolation: bool,
|
||||||
|
|
||||||
|
// Mount policy
|
||||||
|
#[serde(default)]
|
||||||
|
pub mounts: Vec<NormalizedMount>,
|
||||||
|
|
||||||
|
// Resource limits
|
||||||
|
#[serde(default)]
|
||||||
|
pub cpu_shares: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub memory_limit_mb: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LockFile {
|
||||||
|
/// Generate a lock file from a manifest and resolution results.
|
||||||
|
///
|
||||||
|
/// The env_id is computed from the resolved state, ensuring that
|
||||||
|
/// identical resolved dependencies always produce the same identity.
|
||||||
|
pub fn from_resolved(normalized: &NormalizedManifest, resolution: &ResolutionResult) -> Self {
|
||||||
|
let mut resolved_packages = resolution.resolved_packages.clone();
|
||||||
|
resolved_packages.sort();
|
||||||
|
|
||||||
|
let lock = LockFile {
|
||||||
|
lock_version: 2,
|
||||||
|
env_id: String::new(), // computed below
|
||||||
|
short_id: String::new(),
|
||||||
|
base_image: normalized.base_image.clone(),
|
||||||
|
base_image_digest: resolution.base_image_digest.clone(),
|
||||||
|
resolved_packages,
|
||||||
|
resolved_apps: normalized.gui_apps.clone(),
|
||||||
|
runtime_backend: normalized.runtime_backend.clone(),
|
||||||
|
hardware_gpu: normalized.hardware_gpu,
|
||||||
|
hardware_audio: normalized.hardware_audio,
|
||||||
|
network_isolation: normalized.network_isolation,
|
||||||
|
mounts: normalized.mounts.clone(),
|
||||||
|
cpu_shares: normalized.cpu_shares,
|
||||||
|
memory_limit_mb: normalized.memory_limit_mb,
|
||||||
|
};
|
||||||
|
|
||||||
|
let identity = lock.compute_identity();
|
||||||
|
LockFile {
|
||||||
|
env_id: identity.env_id.into_inner(),
|
||||||
|
short_id: identity.short_id.into_inner(),
|
||||||
|
..lock
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the environment identity from the locked state.
|
||||||
|
///
|
||||||
|
/// This is the canonical hash computation. It uses only resolved,
|
||||||
|
/// pinned data — never unresolved package names or image tags.
|
||||||
|
pub fn compute_identity(&self) -> EnvIdentity {
|
||||||
|
let mut hasher = blake3::Hasher::new();
|
||||||
|
|
||||||
|
// Base image: content digest, not tag name
|
||||||
|
hasher.update(format!("base_digest:{}", self.base_image_digest).as_bytes());
|
||||||
|
|
||||||
|
// Resolved packages: name@version (sorted)
|
||||||
|
for pkg in &self.resolved_packages {
|
||||||
|
hasher.update(format!("pkg:{}@{}", pkg.name, pkg.version).as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apps (sorted by normalize)
|
||||||
|
for app in &self.resolved_apps {
|
||||||
|
hasher.update(format!("app:{app}").as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hardware policy
|
||||||
|
if self.hardware_gpu {
|
||||||
|
hasher.update(b"hw:gpu");
|
||||||
|
}
|
||||||
|
if self.hardware_audio {
|
||||||
|
hasher.update(b"hw:audio");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mount policy (sorted by label in normalize)
|
||||||
|
for mount in &self.mounts {
|
||||||
|
hasher.update(
|
||||||
|
format!(
|
||||||
|
"mount:{}:{}:{}",
|
||||||
|
mount.label, mount.host_path, mount.container_path
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runtime backend
|
||||||
|
hasher.update(format!("backend:{}", self.runtime_backend).as_bytes());
|
||||||
|
|
||||||
|
// Network isolation
|
||||||
|
if self.network_isolation {
|
||||||
|
hasher.update(b"net:isolated");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resource limits
|
||||||
|
if let Some(cpu) = self.cpu_shares {
|
||||||
|
hasher.update(format!("cpu:{cpu}").as_bytes());
|
||||||
|
}
|
||||||
|
if let Some(mem) = self.memory_limit_mb {
|
||||||
|
hasher.update(format!("mem:{mem}").as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
let hex = hasher.finalize().to_hex().to_string();
|
||||||
|
let short = hex[..12].to_owned();
|
||||||
|
|
||||||
|
EnvIdentity {
|
||||||
|
env_id: crate::types::EnvId::new(hex),
|
||||||
|
short_id: crate::types::ShortId::new(short),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify that this lock file is internally consistent
|
||||||
|
/// (stored env_id matches recomputed env_id).
|
||||||
|
pub fn verify_integrity(&self) -> Result<EnvIdentity, LockError> {
|
||||||
|
let identity = self.compute_identity();
|
||||||
|
if self.env_id != identity.env_id.as_str() {
|
||||||
|
return Err(LockError::EnvIdMismatch {
|
||||||
|
lock_id: self.env_id.clone(),
|
||||||
|
computed_id: identity.env_id.into_inner(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(identity)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check that a manifest's declared intent matches this lock file.
|
||||||
|
///
|
||||||
|
/// This catches cases where the manifest changed but the lock wasn't updated.
|
||||||
|
pub fn verify_manifest_intent(&self, normalized: &NormalizedManifest) -> Result<(), LockError> {
|
||||||
|
if self.base_image != normalized.base_image {
|
||||||
|
return Err(LockError::ManifestDrift(format!(
|
||||||
|
"base image changed: lock has '{}', manifest has '{}'",
|
||||||
|
self.base_image, normalized.base_image
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if self.runtime_backend != normalized.runtime_backend {
|
||||||
|
return Err(LockError::ManifestDrift(format!(
|
||||||
|
"runtime backend changed: lock has '{}', manifest has '{}'",
|
||||||
|
self.runtime_backend, normalized.runtime_backend
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that all declared packages are present in the lock
|
||||||
|
let locked_names: Vec<&str> = self
|
||||||
|
.resolved_packages
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.name.as_str())
|
||||||
|
.collect();
|
||||||
|
for pkg in &normalized.system_packages {
|
||||||
|
if !locked_names.contains(&pkg.as_str()) {
|
||||||
|
return Err(LockError::ManifestDrift(format!(
|
||||||
|
"package '{pkg}' is in manifest but not in lock file. Run 'karapace build' to re-resolve."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.hardware_gpu != normalized.hardware_gpu
|
||||||
|
|| self.hardware_audio != normalized.hardware_audio
|
||||||
|
{
|
||||||
|
return Err(LockError::ManifestDrift(
|
||||||
|
"hardware policy changed. Run 'karapace build' to re-resolve.".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_to_file(&self, path: impl AsRef<Path>) -> Result<(), LockError> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
let content = toml::to_string_pretty(self)?;
|
||||||
|
let dir = path.parent().unwrap_or(Path::new("."));
|
||||||
|
let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
|
||||||
|
std::io::Write::write_all(&mut tmp, content.as_bytes())?;
|
||||||
|
tmp.as_file().sync_all()?;
|
||||||
|
tmp.persist(path).map_err(|e| LockError::Io(e.error))?;
|
||||||
|
// Fsync parent directory to ensure rename durability on power loss.
|
||||||
|
if let Ok(f) = fs::File::open(dir) {
|
||||||
|
let _ = f.sync_all();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_from_file(path: impl AsRef<Path>) -> Result<Self, LockError> {
|
||||||
|
let content = fs::read_to_string(path)?;
|
||||||
|
Ok(toml::from_str(&content)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::manifest::parse_manifest_str;
|
||||||
|
|
||||||
|
fn sample_normalized() -> NormalizedManifest {
|
||||||
|
parse_manifest_str(
|
||||||
|
r#"
|
||||||
|
manifest_version = 1
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
[system]
|
||||||
|
packages = ["git", "clang"]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.normalize()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_resolution() -> ResolutionResult {
|
||||||
|
ResolutionResult {
|
||||||
|
base_image_digest: "a".repeat(64),
|
||||||
|
resolved_packages: vec![
|
||||||
|
ResolvedPackage {
|
||||||
|
name: "clang".to_owned(),
|
||||||
|
version: "17.0.6-1".to_owned(),
|
||||||
|
},
|
||||||
|
ResolvedPackage {
|
||||||
|
name: "git".to_owned(),
|
||||||
|
version: "2.44.0-1".to_owned(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lock_roundtrip() {
|
||||||
|
let normalized = sample_normalized();
|
||||||
|
let resolution = sample_resolution();
|
||||||
|
let lock = LockFile::from_resolved(&normalized, &resolution);
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("karapace.lock");
|
||||||
|
|
||||||
|
lock.write_to_file(&path).unwrap();
|
||||||
|
let loaded = LockFile::read_from_file(&path).unwrap();
|
||||||
|
assert_eq!(lock, loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lock_integrity_check_passes() {
|
||||||
|
let normalized = sample_normalized();
|
||||||
|
let resolution = sample_resolution();
|
||||||
|
let lock = LockFile::from_resolved(&normalized, &resolution);
|
||||||
|
assert!(lock.verify_integrity().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lock_integrity_fails_on_tamper() {
|
||||||
|
let normalized = sample_normalized();
|
||||||
|
let resolution = sample_resolution();
|
||||||
|
let mut lock = LockFile::from_resolved(&normalized, &resolution);
|
||||||
|
lock.env_id = "tampered".to_owned();
|
||||||
|
assert!(lock.verify_integrity().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lock_contains_real_digest() {
|
||||||
|
let normalized = sample_normalized();
|
||||||
|
let resolution = sample_resolution();
|
||||||
|
let lock = LockFile::from_resolved(&normalized, &resolution);
|
||||||
|
// Digest is the actual image digest, not a hash of the tag name
|
||||||
|
assert_eq!(lock.base_image_digest, "a".repeat(64));
|
||||||
|
assert_eq!(lock.base_image, "rolling");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lock_contains_pinned_versions() {
|
||||||
|
let normalized = sample_normalized();
|
||||||
|
let resolution = sample_resolution();
|
||||||
|
let lock = LockFile::from_resolved(&normalized, &resolution);
|
||||||
|
assert_eq!(lock.resolved_packages.len(), 2);
|
||||||
|
assert_eq!(lock.resolved_packages[0].name, "clang");
|
||||||
|
assert_eq!(lock.resolved_packages[0].version, "17.0.6-1");
|
||||||
|
assert_eq!(lock.resolved_packages[1].name, "git");
|
||||||
|
assert_eq!(lock.resolved_packages[1].version, "2.44.0-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_resolution_same_identity() {
|
||||||
|
let normalized = sample_normalized();
|
||||||
|
let resolution = sample_resolution();
|
||||||
|
let lock1 = LockFile::from_resolved(&normalized, &resolution);
|
||||||
|
let lock2 = LockFile::from_resolved(&normalized, &resolution);
|
||||||
|
assert_eq!(lock1.env_id, lock2.env_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_versions_different_identity() {
|
||||||
|
let normalized = sample_normalized();
|
||||||
|
let res1 = sample_resolution();
|
||||||
|
let mut res2 = sample_resolution();
|
||||||
|
res2.resolved_packages[1].version = "2.45.0-1".to_owned();
|
||||||
|
|
||||||
|
let lock1 = LockFile::from_resolved(&normalized, &res1);
|
||||||
|
let lock2 = LockFile::from_resolved(&normalized, &res2);
|
||||||
|
assert_ne!(lock1.env_id, lock2.env_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_image_digest_different_identity() {
|
||||||
|
let normalized = sample_normalized();
|
||||||
|
let mut res1 = sample_resolution();
|
||||||
|
let mut res2 = sample_resolution();
|
||||||
|
res1.base_image_digest = "a".repeat(64);
|
||||||
|
res2.base_image_digest = "b".repeat(64);
|
||||||
|
|
||||||
|
let lock1 = LockFile::from_resolved(&normalized, &res1);
|
||||||
|
let lock2 = LockFile::from_resolved(&normalized, &res2);
|
||||||
|
assert_ne!(lock1.env_id, lock2.env_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_intent_verified() {
|
||||||
|
let normalized = sample_normalized();
|
||||||
|
let resolution = sample_resolution();
|
||||||
|
let lock = LockFile::from_resolved(&normalized, &resolution);
|
||||||
|
assert!(lock.verify_manifest_intent(&normalized).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_drift_detected() {
|
||||||
|
let normalized = sample_normalized();
|
||||||
|
let resolution = sample_resolution();
|
||||||
|
let lock = LockFile::from_resolved(&normalized, &resolution);
|
||||||
|
|
||||||
|
// Change the manifest
|
||||||
|
let mut drifted = normalized.clone();
|
||||||
|
drifted.base_image = "ubuntu/24.04".to_owned();
|
||||||
|
assert!(lock.verify_manifest_intent(&drifted).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn includes_hardware_policy_in_identity() {
|
||||||
|
let mut n1 = sample_normalized();
|
||||||
|
let mut n2 = sample_normalized();
|
||||||
|
n1.hardware_gpu = false;
|
||||||
|
n2.hardware_gpu = true;
|
||||||
|
let res = sample_resolution();
|
||||||
|
let lock1 = LockFile::from_resolved(&n1, &res);
|
||||||
|
let lock2 = LockFile::from_resolved(&n2, &res);
|
||||||
|
assert_ne!(lock1.env_id, lock2.env_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- A1: Determinism Hardening ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hash_stable_across_repeated_invocations() {
|
||||||
|
let normalized = sample_normalized();
|
||||||
|
let resolution = sample_resolution();
|
||||||
|
let mut ids = Vec::new();
|
||||||
|
for _ in 0..100 {
|
||||||
|
let lock = LockFile::from_resolved(&normalized, &resolution);
|
||||||
|
ids.push(lock.env_id.clone());
|
||||||
|
}
|
||||||
|
let first = &ids[0];
|
||||||
|
for (i, id) in ids.iter().enumerate() {
|
||||||
|
assert_eq!(first, id, "invocation {i} produced different env_id");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hash_stable_with_randomized_package_order() {
|
||||||
|
let normalized = sample_normalized();
|
||||||
|
// Create resolutions with packages in different orders
|
||||||
|
let res_ab = ResolutionResult {
|
||||||
|
base_image_digest: "a".repeat(64),
|
||||||
|
resolved_packages: vec![
|
||||||
|
ResolvedPackage {
|
||||||
|
name: "alpha".to_owned(),
|
||||||
|
version: "1.0".to_owned(),
|
||||||
|
},
|
||||||
|
ResolvedPackage {
|
||||||
|
name: "beta".to_owned(),
|
||||||
|
version: "2.0".to_owned(),
|
||||||
|
},
|
||||||
|
ResolvedPackage {
|
||||||
|
name: "gamma".to_owned(),
|
||||||
|
version: "3.0".to_owned(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let res_ba = ResolutionResult {
|
||||||
|
base_image_digest: "a".repeat(64),
|
||||||
|
resolved_packages: vec![
|
||||||
|
ResolvedPackage {
|
||||||
|
name: "gamma".to_owned(),
|
||||||
|
version: "3.0".to_owned(),
|
||||||
|
},
|
||||||
|
ResolvedPackage {
|
||||||
|
name: "alpha".to_owned(),
|
||||||
|
version: "1.0".to_owned(),
|
||||||
|
},
|
||||||
|
ResolvedPackage {
|
||||||
|
name: "beta".to_owned(),
|
||||||
|
version: "2.0".to_owned(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let lock_ab = LockFile::from_resolved(&normalized, &res_ab);
|
||||||
|
let lock_ba = LockFile::from_resolved(&normalized, &res_ba);
|
||||||
|
assert_eq!(
|
||||||
|
lock_ab.env_id, lock_ba.env_id,
|
||||||
|
"package order must not affect env_id (sorted in from_resolved)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hash_stable_with_randomized_mount_order() {
|
||||||
|
use crate::normalize::NormalizedMount;
|
||||||
|
let mut n1 = sample_normalized();
|
||||||
|
n1.mounts = vec![
|
||||||
|
NormalizedMount {
|
||||||
|
label: "cache".to_owned(),
|
||||||
|
host_path: "/a".to_owned(),
|
||||||
|
container_path: "/b".to_owned(),
|
||||||
|
},
|
||||||
|
NormalizedMount {
|
||||||
|
label: "work".to_owned(),
|
||||||
|
host_path: "/c".to_owned(),
|
||||||
|
container_path: "/d".to_owned(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let mut n2 = sample_normalized();
|
||||||
|
n2.mounts = vec![
|
||||||
|
NormalizedMount {
|
||||||
|
label: "work".to_owned(),
|
||||||
|
host_path: "/c".to_owned(),
|
||||||
|
container_path: "/d".to_owned(),
|
||||||
|
},
|
||||||
|
NormalizedMount {
|
||||||
|
label: "cache".to_owned(),
|
||||||
|
host_path: "/a".to_owned(),
|
||||||
|
container_path: "/b".to_owned(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
// Mounts are sorted by label in normalize(), but from_resolved doesn't re-sort.
|
||||||
|
// The hash input iterates mounts in order. For determinism, mounts must be
|
||||||
|
// pre-sorted by the caller (normalize). Test that identical sorted mounts hash equally.
|
||||||
|
n1.mounts.sort_by(|a, b| a.label.cmp(&b.label));
|
||||||
|
n2.mounts.sort_by(|a, b| a.label.cmp(&b.label));
|
||||||
|
let res = sample_resolution();
|
||||||
|
let lock1 = LockFile::from_resolved(&n1, &res);
|
||||||
|
let lock2 = LockFile::from_resolved(&n2, &res);
|
||||||
|
assert_eq!(lock1.env_id, lock2.env_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cross_platform_path_normalization() {
|
||||||
|
// Verify that path separators in mount specs don't break determinism.
|
||||||
|
// On all platforms, mount paths are stored as-is from the manifest
|
||||||
|
// (which uses forward slashes). This test confirms no OS-dependent
|
||||||
|
// path mangling occurs.
|
||||||
|
use crate::normalize::NormalizedMount;
|
||||||
|
let mut n1 = sample_normalized();
|
||||||
|
n1.mounts = vec![NormalizedMount {
|
||||||
|
label: "src".to_owned(),
|
||||||
|
host_path: "/home/user/src".to_owned(),
|
||||||
|
container_path: "/workspace".to_owned(),
|
||||||
|
}];
|
||||||
|
let res = sample_resolution();
|
||||||
|
let lock = LockFile::from_resolved(&n1, &res);
|
||||||
|
|
||||||
|
// The env_id must be a fixed known value regardless of platform
|
||||||
|
let lock2 = LockFile::from_resolved(&n1, &res);
|
||||||
|
assert_eq!(lock.env_id, lock2.env_id);
|
||||||
|
// env_id must be exactly 64 hex chars
|
||||||
|
assert_eq!(lock.env_id.len(), 64);
|
||||||
|
assert!(lock.env_id.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identical_inputs_produce_identical_hash_bytes() {
|
||||||
|
let normalized = sample_normalized();
|
||||||
|
let resolution = sample_resolution();
|
||||||
|
let lock1 = LockFile::from_resolved(&normalized, &resolution);
|
||||||
|
let lock2 = LockFile::from_resolved(&normalized, &resolution);
|
||||||
|
// Byte-level comparison of the full 64-char hex string
|
||||||
|
assert_eq!(
|
||||||
|
lock1.env_id.as_bytes(),
|
||||||
|
lock2.env_id.as_bytes(),
|
||||||
|
"hash bytes must be identical for identical inputs"
|
||||||
|
);
|
||||||
|
assert_eq!(lock1.short_id.as_bytes(), lock2.short_id.as_bytes(),);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- IG-M5: Golden-value cross-machine determinism tests ---
|
||||||
|
//
|
||||||
|
// These tests hardcode expected blake3 hashes for fixed inputs.
|
||||||
|
// If any of these fail, it means compute_identity() has changed behavior,
|
||||||
|
// which would break cross-machine reproducibility and existing lock files.
|
||||||
|
// The golden values were computed once and must remain stable forever.
|
||||||
|
|
||||||
|
fn golden_lock(
|
||||||
|
base_digest: &str,
|
||||||
|
packages: &[(&str, &str)],
|
||||||
|
mounts: &[(&str, &str, &str)],
|
||||||
|
backend: &str,
|
||||||
|
gpu: bool,
|
||||||
|
audio: bool,
|
||||||
|
network_isolation: bool,
|
||||||
|
) -> LockFile {
|
||||||
|
let resolved_packages: Vec<ResolvedPackage> = packages
|
||||||
|
.iter()
|
||||||
|
.map(|(n, v)| ResolvedPackage {
|
||||||
|
name: n.to_string(),
|
||||||
|
version: v.to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mount_specs: Vec<NormalizedMount> = mounts
|
||||||
|
.iter()
|
||||||
|
.map(|(l, h, c)| NormalizedMount {
|
||||||
|
label: l.to_string(),
|
||||||
|
host_path: h.to_string(),
|
||||||
|
container_path: c.to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let normalized = NormalizedManifest {
|
||||||
|
manifest_version: 1,
|
||||||
|
base_image: "rolling".to_owned(),
|
||||||
|
system_packages: packages.iter().map(|(n, _)| n.to_string()).collect(),
|
||||||
|
gui_apps: Vec::new(),
|
||||||
|
hardware_gpu: gpu,
|
||||||
|
hardware_audio: audio,
|
||||||
|
mounts: mount_specs,
|
||||||
|
runtime_backend: backend.to_owned(),
|
||||||
|
network_isolation,
|
||||||
|
cpu_shares: None,
|
||||||
|
memory_limit_mb: None,
|
||||||
|
};
|
||||||
|
let resolution = ResolutionResult {
|
||||||
|
base_image_digest: base_digest.to_owned(),
|
||||||
|
resolved_packages,
|
||||||
|
};
|
||||||
|
LockFile::from_resolved(&normalized, &resolution)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn golden_identity_empty_manifest() {
|
||||||
|
let lock = golden_lock("sha256:abc123", &[], &[], "mock", false, false, false);
|
||||||
|
assert_eq!(
|
||||||
|
lock.env_id, "aabaeaeda3b27db42054f64719a16afd49e72b4fc6e8493e2fce9d862d240806",
|
||||||
|
"golden hash for empty manifest must be stable across all platforms"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn golden_identity_with_packages() {
|
||||||
|
let lock = golden_lock(
|
||||||
|
"sha256:abc123",
|
||||||
|
&[("curl", "7.88.1"), ("git", "2.39.2")],
|
||||||
|
&[],
|
||||||
|
"namespace",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lock.env_id, "dfea3163e5925ee788a97fae24d9ec08f774c29c64c9180befe771d877e62f18",
|
||||||
|
"golden hash for manifest with packages must be stable across all platforms"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn golden_identity_with_mounts_and_hardware() {
|
||||||
|
let lock = golden_lock(
|
||||||
|
"sha256:abc123",
|
||||||
|
&[("vim", "9.0.1")],
|
||||||
|
&[("home", "/home/user", "/home")],
|
||||||
|
"namespace",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lock.env_id, "d6ca89829da264240d0508bd58bffc28c2014f643426bbecff3db5a525793546",
|
||||||
|
"golden hash for manifest with mounts+hardware must be stable across all platforms"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn golden_identity_network_isolation_differs() {
|
||||||
|
let lock = golden_lock("sha256:abc123", &[], &[], "mock", false, false, true);
|
||||||
|
assert_eq!(
|
||||||
|
lock.env_id, "dcdae57b3749d0aa2d3948de9fde99ceedad34deaef9b618c2d9f939dac25596",
|
||||||
|
"golden hash for network-isolated manifest must be stable across all platforms"
|
||||||
|
);
|
||||||
|
// Must differ from the non-isolated empty manifest
|
||||||
|
assert_ne!(
|
||||||
|
lock.env_id, "aabaeaeda3b27db42054f64719a16afd49e72b4fc6e8493e2fce9d862d240806",
|
||||||
|
"network isolation must produce a different hash"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn golden_lock_full(
|
||||||
|
base_digest: &str,
|
||||||
|
packages: &[(&str, &str)],
|
||||||
|
mounts: &[(&str, &str, &str)],
|
||||||
|
apps: &[&str],
|
||||||
|
backend: &str,
|
||||||
|
gpu: bool,
|
||||||
|
audio: bool,
|
||||||
|
network_isolation: bool,
|
||||||
|
cpu_shares: Option<u64>,
|
||||||
|
memory_limit_mb: Option<u64>,
|
||||||
|
) -> LockFile {
|
||||||
|
let resolved_packages: Vec<ResolvedPackage> = packages
|
||||||
|
.iter()
|
||||||
|
.map(|(n, v)| ResolvedPackage {
|
||||||
|
name: n.to_string(),
|
||||||
|
version: v.to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mount_specs: Vec<NormalizedMount> = mounts
|
||||||
|
.iter()
|
||||||
|
.map(|(l, h, c)| NormalizedMount {
|
||||||
|
label: l.to_string(),
|
||||||
|
host_path: h.to_string(),
|
||||||
|
container_path: c.to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let normalized = NormalizedManifest {
|
||||||
|
manifest_version: 1,
|
||||||
|
base_image: "rolling".to_owned(),
|
||||||
|
system_packages: packages.iter().map(|(n, _)| n.to_string()).collect(),
|
||||||
|
gui_apps: apps.iter().map(ToString::to_string).collect(),
|
||||||
|
hardware_gpu: gpu,
|
||||||
|
hardware_audio: audio,
|
||||||
|
mounts: mount_specs,
|
||||||
|
runtime_backend: backend.to_owned(),
|
||||||
|
network_isolation,
|
||||||
|
cpu_shares,
|
||||||
|
memory_limit_mb,
|
||||||
|
};
|
||||||
|
let resolution = ResolutionResult {
|
||||||
|
base_image_digest: base_digest.to_owned(),
|
||||||
|
resolved_packages,
|
||||||
|
};
|
||||||
|
LockFile::from_resolved(&normalized, &resolution)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn golden_identity_with_cpu_shares() {
|
||||||
|
let lock = golden_lock_full(
|
||||||
|
"sha256:abc123",
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
"mock",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
Some(1024),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lock.env_id, "d966f9ee1c5e8959ae29d0483c45fc66813ec47201aa9f26c6371336b3dfd252",
|
||||||
|
"golden hash for cpu_shares=1024 must be stable across all platforms"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn golden_identity_with_memory_limit() {
|
||||||
|
let lock = golden_lock_full(
|
||||||
|
"sha256:abc123",
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
"mock",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
Some(4096),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lock.env_id, "74823889e305b7b28394508b5813568faf9c814b4ef8f1f97e8d3dcd9a7a6bae",
|
||||||
|
"golden hash for memory_limit_mb=4096 must be stable across all platforms"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn golden_identity_with_apps() {
|
||||||
|
let lock = golden_lock_full(
|
||||||
|
"sha256:abc123",
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
&["firefox", "code"],
|
||||||
|
"mock",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lock.env_id, "1aaf066c7b1e18178e838b0cf33c0bc67cd7401e586df826daa9033178ccfdf3",
|
||||||
|
"golden hash for gui_apps=[firefox,code] must be stable across all platforms"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn golden_identity_with_cpu_and_memory() {
|
||||||
|
let lock = golden_lock_full(
|
||||||
|
"sha256:abc123",
|
||||||
|
&[("curl", "7.88.1")],
|
||||||
|
&[("data", "/mnt/data", "/data")],
|
||||||
|
&["vlc"],
|
||||||
|
"namespace",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
Some(2048),
|
||||||
|
Some(8192),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lock.env_id, "44f9547036b4f24f8fe32844f2672804020c6260e29b7f72e17fd29d441ebc27",
|
||||||
|
"golden hash for fully-populated manifest must be stable across all platforms"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn golden_identity_gpu_only_differs_from_audio_only() {
|
||||||
|
let gpu_lock = golden_lock_full(
|
||||||
|
"sha256:abc123",
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
"mock",
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let audio_lock = golden_lock_full(
|
||||||
|
"sha256:abc123",
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
"mock",
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
gpu_lock.env_id, "f761765ba48777bcc64c2cd5169cb44be27bcd2d6587c64c28bc98fa0964b266",
|
||||||
|
"golden hash for gpu-only must be stable"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
audio_lock.env_id, "428d91b41a03c1625e01bab1278ef231fb186833bff80a6bdc8227a2276f4318",
|
||||||
|
"golden hash for audio-only must be stable"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
gpu_lock.env_id, audio_lock.env_id,
|
||||||
|
"gpu-only and audio-only must produce different hashes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hash_sensitive_to_all_fields() {
|
||||||
|
let base_norm = sample_normalized();
|
||||||
|
let base_res = sample_resolution();
|
||||||
|
let base_id = LockFile::from_resolved(&base_norm, &base_res).env_id;
|
||||||
|
|
||||||
|
// Change each field and verify the hash changes
|
||||||
|
let mut n = base_norm.clone();
|
||||||
|
n.network_isolation = !n.network_isolation;
|
||||||
|
assert_ne!(
|
||||||
|
LockFile::from_resolved(&n, &base_res).env_id,
|
||||||
|
base_id,
|
||||||
|
"network_isolation"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut n = base_norm.clone();
|
||||||
|
n.cpu_shares = Some(1024);
|
||||||
|
assert_ne!(
|
||||||
|
LockFile::from_resolved(&n, &base_res).env_id,
|
||||||
|
base_id,
|
||||||
|
"cpu_shares"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut n = base_norm.clone();
|
||||||
|
n.memory_limit_mb = Some(4096);
|
||||||
|
assert_ne!(
|
||||||
|
LockFile::from_resolved(&n, &base_res).env_id,
|
||||||
|
base_id,
|
||||||
|
"memory_limit_mb"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut n = base_norm.clone();
|
||||||
|
n.runtime_backend = "oci".to_owned();
|
||||||
|
assert_ne!(
|
||||||
|
LockFile::from_resolved(&n, &base_res).env_id,
|
||||||
|
base_id,
|
||||||
|
"runtime_backend"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut n = base_norm.clone();
|
||||||
|
n.gui_apps = vec!["new-app".to_owned()];
|
||||||
|
assert_ne!(
|
||||||
|
LockFile::from_resolved(&n, &base_res).env_id,
|
||||||
|
base_id,
|
||||||
|
"gui_apps"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
192
crates/karapace-schema/src/manifest.rs
Normal file
192
crates/karapace-schema/src/manifest.rs
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ManifestError {
|
||||||
|
#[error("failed to read manifest file: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("failed to parse manifest: {0}")]
|
||||||
|
ParseToml(#[from] toml::de::Error),
|
||||||
|
#[error("unsupported manifest_version: {0}, expected 1")]
|
||||||
|
UnsupportedVersion(u32),
|
||||||
|
#[error("base.image must not be empty")]
|
||||||
|
EmptyBaseImage,
|
||||||
|
#[error("mount label must not be empty")]
|
||||||
|
EmptyMountLabel,
|
||||||
|
#[error("invalid mount declaration for '{label}': '{spec}', expected '<host>:<container>'")]
|
||||||
|
InvalidMount { label: String, spec: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ManifestV1 {
|
||||||
|
pub manifest_version: u32,
|
||||||
|
pub base: BaseSection,
|
||||||
|
#[serde(default)]
|
||||||
|
pub system: SystemSection,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gui: GuiSection,
|
||||||
|
#[serde(default)]
|
||||||
|
pub hardware: HardwareSection,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mounts: MountsSection,
|
||||||
|
#[serde(default)]
|
||||||
|
pub runtime: RuntimeSection,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct BaseSection {
|
||||||
|
pub image: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SystemSection {
|
||||||
|
#[serde(default)]
|
||||||
|
pub packages: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct GuiSection {
|
||||||
|
#[serde(default)]
|
||||||
|
pub apps: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct HardwareSection {
|
||||||
|
#[serde(default)]
|
||||||
|
pub gpu: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub audio: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
pub struct MountsSection {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub entries: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RuntimeSection {
|
||||||
|
#[serde(default = "default_backend")]
|
||||||
|
pub backend: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub network_isolation: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub resource_limits: ResourceLimits,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RuntimeSection {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
backend: default_backend(),
|
||||||
|
network_isolation: false,
|
||||||
|
resource_limits: ResourceLimits::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ResourceLimits {
|
||||||
|
#[serde(default)]
|
||||||
|
pub cpu_shares: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub memory_limit_mb: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_backend() -> String {
|
||||||
|
"namespace".to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_manifest_str(input: &str) -> Result<ManifestV1, ManifestError> {
|
||||||
|
Ok(toml::from_str(input)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_manifest_file(path: impl AsRef<Path>) -> Result<ManifestV1, ManifestError> {
|
||||||
|
let content = fs::read_to_string(path)?;
|
||||||
|
parse_manifest_str(&content)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_full_manifest() {
|
||||||
|
let input = r#"
|
||||||
|
manifest_version = 1
|
||||||
|
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
|
||||||
|
[system]
|
||||||
|
packages = ["clang", "cmake", "git"]
|
||||||
|
|
||||||
|
[gui]
|
||||||
|
apps = ["ide", "debugger"]
|
||||||
|
|
||||||
|
[hardware]
|
||||||
|
gpu = true
|
||||||
|
audio = true
|
||||||
|
|
||||||
|
[mounts]
|
||||||
|
workspace = "./:/workspace"
|
||||||
|
|
||||||
|
[runtime]
|
||||||
|
backend = "oci"
|
||||||
|
network_isolation = true
|
||||||
|
|
||||||
|
[runtime.resource_limits]
|
||||||
|
cpu_shares = 1024
|
||||||
|
memory_limit_mb = 4096
|
||||||
|
"#;
|
||||||
|
let manifest = parse_manifest_str(input).expect("should parse");
|
||||||
|
assert_eq!(manifest.manifest_version, 1);
|
||||||
|
assert_eq!(manifest.base.image, "rolling");
|
||||||
|
assert_eq!(manifest.system.packages.len(), 3);
|
||||||
|
assert_eq!(manifest.runtime.backend, "oci");
|
||||||
|
assert!(manifest.runtime.network_isolation);
|
||||||
|
assert_eq!(manifest.runtime.resource_limits.cpu_shares, Some(1024));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_minimal_manifest() {
|
||||||
|
let input = r#"
|
||||||
|
manifest_version = 1
|
||||||
|
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
"#;
|
||||||
|
let manifest = parse_manifest_str(input).expect("should parse");
|
||||||
|
assert_eq!(manifest.runtime.backend, "namespace");
|
||||||
|
assert!(!manifest.runtime.network_isolation);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unknown_fields() {
|
||||||
|
let input = r#"
|
||||||
|
manifest_version = 1
|
||||||
|
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
unknown_field = true
|
||||||
|
"#;
|
||||||
|
assert!(parse_manifest_str(input).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_missing_base() {
|
||||||
|
let input = r"
|
||||||
|
manifest_version = 1
|
||||||
|
";
|
||||||
|
assert!(parse_manifest_str(input).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
224
crates/karapace-schema/src/normalize.rs
Normal file
224
crates/karapace-schema/src/normalize.rs
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
use crate::manifest::{ManifestError, ManifestV1};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Canonical, sorted, deduplicated representation of a parsed manifest.
|
||||||
|
///
|
||||||
|
/// All optional fields are resolved to defaults, packages are sorted, and mounts
|
||||||
|
/// are validated. This is the input to identity hashing and lock file generation.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct NormalizedManifest {
|
||||||
|
pub manifest_version: u32,
|
||||||
|
pub base_image: String,
|
||||||
|
pub system_packages: Vec<String>,
|
||||||
|
pub gui_apps: Vec<String>,
|
||||||
|
pub hardware_gpu: bool,
|
||||||
|
pub hardware_audio: bool,
|
||||||
|
pub mounts: Vec<NormalizedMount>,
|
||||||
|
pub runtime_backend: String,
|
||||||
|
pub network_isolation: bool,
|
||||||
|
pub cpu_shares: Option<u64>,
|
||||||
|
pub memory_limit_mb: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A validated bind-mount specification with label, host path, and container path.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct NormalizedMount {
|
||||||
|
pub label: String,
|
||||||
|
pub host_path: String,
|
||||||
|
pub container_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ManifestV1 {
|
||||||
|
/// Normalize the manifest: validate fields, sort packages, resolve defaults.
|
||||||
|
pub fn normalize(&self) -> Result<NormalizedManifest, ManifestError> {
|
||||||
|
if self.manifest_version != 1 {
|
||||||
|
return Err(ManifestError::UnsupportedVersion(self.manifest_version));
|
||||||
|
}
|
||||||
|
|
||||||
|
let base_image = self.base.image.trim().to_owned();
|
||||||
|
if base_image.is_empty() {
|
||||||
|
return Err(ManifestError::EmptyBaseImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut mounts = Vec::with_capacity(self.mounts.entries.len());
|
||||||
|
for (label, spec) in &self.mounts.entries {
|
||||||
|
let trimmed_label = label.trim().to_owned();
|
||||||
|
if trimmed_label.is_empty() {
|
||||||
|
return Err(ManifestError::EmptyMountLabel);
|
||||||
|
}
|
||||||
|
let (host_path, container_path) = parse_mount_spec(label, spec)?;
|
||||||
|
mounts.push(NormalizedMount {
|
||||||
|
label: trimmed_label,
|
||||||
|
host_path,
|
||||||
|
container_path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
mounts.sort_by(|a, b| a.label.cmp(&b.label));
|
||||||
|
|
||||||
|
let runtime_backend = self.runtime.backend.trim().to_lowercase();
|
||||||
|
|
||||||
|
Ok(NormalizedManifest {
|
||||||
|
manifest_version: self.manifest_version,
|
||||||
|
base_image,
|
||||||
|
system_packages: normalize_string_list(&self.system.packages),
|
||||||
|
gui_apps: normalize_string_list(&self.gui.apps),
|
||||||
|
hardware_gpu: self.hardware.gpu,
|
||||||
|
hardware_audio: self.hardware.audio,
|
||||||
|
mounts,
|
||||||
|
runtime_backend,
|
||||||
|
network_isolation: self.runtime.network_isolation,
|
||||||
|
cpu_shares: self.runtime.resource_limits.cpu_shares,
|
||||||
|
memory_limit_mb: self.runtime.resource_limits.memory_limit_mb,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NormalizedManifest {
|
||||||
|
pub fn canonical_json(&self) -> String {
|
||||||
|
serde_json::to_string(self).expect("normalized manifest serialization is infallible")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_mount_spec(label: &str, spec: &str) -> Result<(String, String), ManifestError> {
|
||||||
|
let Some((host_raw, container_raw)) = spec.split_once(':') else {
|
||||||
|
return Err(ManifestError::InvalidMount {
|
||||||
|
label: label.to_owned(),
|
||||||
|
spec: spec.to_owned(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let host_path = host_raw.trim().to_owned();
|
||||||
|
let container_path = container_raw.trim().to_owned();
|
||||||
|
|
||||||
|
if host_path.is_empty() || container_path.is_empty() {
|
||||||
|
return Err(ManifestError::InvalidMount {
|
||||||
|
label: label.to_owned(),
|
||||||
|
spec: spec.to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((host_path, container_path))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_string_list(values: &[String]) -> Vec<String> {
|
||||||
|
let mut out: Vec<String> = values
|
||||||
|
.iter()
|
||||||
|
.map(|v| v.trim().to_owned())
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.collect();
|
||||||
|
out.sort();
|
||||||
|
out.dedup();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::manifest::parse_manifest_str;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_and_sorts_deterministically() {
|
||||||
|
let input = r#"
|
||||||
|
manifest_version = 1
|
||||||
|
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
|
||||||
|
[system]
|
||||||
|
packages = ["git", "cmake", "git", "clang"]
|
||||||
|
|
||||||
|
[gui]
|
||||||
|
apps = ["debugger", "ide"]
|
||||||
|
|
||||||
|
[hardware]
|
||||||
|
gpu = true
|
||||||
|
audio = false
|
||||||
|
|
||||||
|
[mounts]
|
||||||
|
workspace = "./:/workspace"
|
||||||
|
cache = "~/.cache:/cache"
|
||||||
|
"#;
|
||||||
|
let manifest = parse_manifest_str(input).unwrap();
|
||||||
|
let normalized = manifest.normalize().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(normalized.system_packages, vec!["clang", "cmake", "git"]);
|
||||||
|
assert_eq!(normalized.gui_apps, vec!["debugger", "ide"]);
|
||||||
|
assert_eq!(normalized.mounts[0].label, "cache");
|
||||||
|
assert_eq!(normalized.mounts[1].label, "workspace");
|
||||||
|
assert_eq!(normalized.runtime_backend, "namespace");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn equivalent_manifests_produce_same_canonical_json() {
|
||||||
|
let a = parse_manifest_str(
|
||||||
|
r#"
|
||||||
|
manifest_version = 1
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
[system]
|
||||||
|
packages = ["git", "clang"]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.normalize()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let b = parse_manifest_str(
|
||||||
|
r#"
|
||||||
|
manifest_version = 1
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
[system]
|
||||||
|
packages = ["clang", "git"]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.normalize()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(a.canonical_json(), b.canonical_json());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_empty_base_image() {
|
||||||
|
let manifest = parse_manifest_str(
|
||||||
|
r#"
|
||||||
|
manifest_version = 1
|
||||||
|
[base]
|
||||||
|
image = " "
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(manifest.normalize().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_invalid_mount_spec() {
|
||||||
|
let manifest = parse_manifest_str(
|
||||||
|
r#"
|
||||||
|
manifest_version = 1
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
[mounts]
|
||||||
|
workspace = "./no-colon"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(manifest.normalize().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_backend_included_in_normalization() {
|
||||||
|
let manifest = parse_manifest_str(
|
||||||
|
r#"
|
||||||
|
manifest_version = 1
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
[runtime]
|
||||||
|
backend = "OCI"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let normalized = manifest.normalize().unwrap();
|
||||||
|
assert_eq!(normalized.runtime_backend, "oci");
|
||||||
|
}
|
||||||
|
}
|
||||||
143
crates/karapace-schema/src/preset.rs
Normal file
143
crates/karapace-schema/src/preset.rs
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct Preset {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub description: &'static str,
|
||||||
|
pub manifest: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const BUILTIN_PRESETS: &[Preset] = &[
|
||||||
|
Preset {
|
||||||
|
name: "dev",
|
||||||
|
description: "Development environment with common build tools",
|
||||||
|
manifest: r#"manifest_version = 1
|
||||||
|
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
|
||||||
|
[system]
|
||||||
|
packages = ["git", "curl", "wget", "vim", "gcc", "make", "cmake"]
|
||||||
|
|
||||||
|
[runtime]
|
||||||
|
backend = "namespace"
|
||||||
|
"#,
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "dev-rust",
|
||||||
|
description: "Rust development environment",
|
||||||
|
manifest: r#"manifest_version = 1
|
||||||
|
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
|
||||||
|
[system]
|
||||||
|
packages = ["git", "curl", "gcc", "make", "rustup"]
|
||||||
|
|
||||||
|
[runtime]
|
||||||
|
backend = "namespace"
|
||||||
|
"#,
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "dev-python",
|
||||||
|
description: "Python development environment",
|
||||||
|
manifest: r#"manifest_version = 1
|
||||||
|
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
|
||||||
|
[system]
|
||||||
|
packages = ["git", "curl", "python3", "python3-pip", "python3-venv"]
|
||||||
|
|
||||||
|
[runtime]
|
||||||
|
backend = "namespace"
|
||||||
|
"#,
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "gui-app",
|
||||||
|
description: "GUI application environment with GPU and audio passthrough",
|
||||||
|
manifest: r#"manifest_version = 1
|
||||||
|
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
|
||||||
|
[hardware]
|
||||||
|
gpu = true
|
||||||
|
audio = true
|
||||||
|
|
||||||
|
[runtime]
|
||||||
|
backend = "namespace"
|
||||||
|
"#,
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "gaming",
|
||||||
|
description: "Gaming environment with GPU, audio, and Vulkan support",
|
||||||
|
manifest: r#"manifest_version = 1
|
||||||
|
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
|
||||||
|
[system]
|
||||||
|
packages = ["mesa-dri", "vulkan-loader", "libvulkan1", "alsa-plugins"]
|
||||||
|
|
||||||
|
[hardware]
|
||||||
|
gpu = true
|
||||||
|
audio = true
|
||||||
|
|
||||||
|
[runtime]
|
||||||
|
backend = "namespace"
|
||||||
|
"#,
|
||||||
|
},
|
||||||
|
Preset {
|
||||||
|
name: "minimal",
|
||||||
|
description: "Minimal environment with no extra packages",
|
||||||
|
manifest: r#"manifest_version = 1
|
||||||
|
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
|
||||||
|
[runtime]
|
||||||
|
backend = "namespace"
|
||||||
|
"#,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn get_preset(name: &str) -> Option<&'static Preset> {
|
||||||
|
BUILTIN_PRESETS.iter().find(|p| p.name == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_presets() -> &'static [Preset] {
|
||||||
|
BUILTIN_PRESETS
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_presets_parse() {
|
||||||
|
for preset in BUILTIN_PRESETS {
|
||||||
|
let result = crate::parse_manifest_str(preset.manifest);
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"preset '{}' failed to parse: {:?}",
|
||||||
|
preset.name,
|
||||||
|
result.err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_preset_by_name() {
|
||||||
|
assert!(get_preset("dev").is_some());
|
||||||
|
assert!(get_preset("nonexistent").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_presets_have_unique_names() {
|
||||||
|
let mut names: Vec<&str> = BUILTIN_PRESETS.iter().map(|p| p.name).collect();
|
||||||
|
names.sort_unstable();
|
||||||
|
names.dedup();
|
||||||
|
assert_eq!(names.len(), BUILTIN_PRESETS.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
158
crates/karapace-schema/src/types.rs
Normal file
158
crates/karapace-schema/src/types.rs
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
//! Newtype wrappers for string identifiers, providing compile-time type safety.
|
||||||
|
//!
|
||||||
|
//! All newtypes serialize/deserialize as plain strings for backward compatibility.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fmt;
|
||||||
|
use std::ops::Deref;
|
||||||
|
|
||||||
|
macro_rules! string_newtype {
|
||||||
|
($(#[$meta:meta])* $name:ident) => {
|
||||||
|
$(#[$meta])*
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct $name(String);
|
||||||
|
|
||||||
|
impl $name {
|
||||||
|
/// Create a new instance from a string.
|
||||||
|
pub fn new(s: impl Into<String>) -> Self {
|
||||||
|
Self(s.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the inner string as a slice.
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume self and return the inner `String`.
|
||||||
|
pub fn into_inner(self) -> String {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deref for $name {
|
||||||
|
type Target = str;
|
||||||
|
fn deref(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for $name {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRef<str> for $name {
|
||||||
|
fn as_ref(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialEq<str> for $name {
|
||||||
|
fn eq(&self, other: &str) -> bool {
|
||||||
|
self.0 == other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialEq<String> for $name {
|
||||||
|
fn eq(&self, other: &String) -> bool {
|
||||||
|
self.0 == *other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialEq<$name> for String {
|
||||||
|
fn eq(&self, other: &$name) -> bool {
|
||||||
|
*self == other.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRef<std::path::Path> for $name {
|
||||||
|
fn as_ref(&self) -> &std::path::Path {
|
||||||
|
std::path::Path::new(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for $name {
|
||||||
|
fn from(s: String) -> Self {
|
||||||
|
Self(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for $name {
|
||||||
|
fn from(s: &str) -> Self {
|
||||||
|
Self(s.to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
string_newtype!(
|
||||||
|
/// Full 64-character hex environment identifier, derived from locked manifest content.
|
||||||
|
EnvId
|
||||||
|
);
|
||||||
|
|
||||||
|
string_newtype!(
|
||||||
|
/// Truncated 12-character prefix of an [`EnvId`], used for display.
|
||||||
|
ShortId
|
||||||
|
);
|
||||||
|
|
||||||
|
string_newtype!(
|
||||||
|
/// Blake3 hash of a content-addressable object in the store.
|
||||||
|
ObjectHash
|
||||||
|
);
|
||||||
|
|
||||||
|
string_newtype!(
|
||||||
|
/// Blake3 hash identifying a layer manifest.
|
||||||
|
LayerHash
|
||||||
|
);
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_id_display_and_as_ref() {
|
||||||
|
let id = EnvId::new("abc123");
|
||||||
|
assert_eq!(id.to_string(), "abc123");
|
||||||
|
assert_eq!(id.as_str(), "abc123");
|
||||||
|
assert_eq!(AsRef::<str>::as_ref(&id), "abc123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_id_serde_roundtrip() {
|
||||||
|
let id = EnvId::new("deadbeef");
|
||||||
|
let json = serde_json::to_string(&id).unwrap();
|
||||||
|
assert_eq!(json, "\"deadbeef\"");
|
||||||
|
let back: EnvId = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_id_from_str() {
|
||||||
|
let sid = ShortId::from("abc123def456");
|
||||||
|
assert_eq!(sid.as_str(), "abc123def456");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_hash_into_inner() {
|
||||||
|
let h = ObjectHash::new("hash_value".to_owned());
|
||||||
|
assert_eq!(h.into_inner(), "hash_value");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn layer_hash_equality() {
|
||||||
|
let a = LayerHash::new("same");
|
||||||
|
let b = LayerHash::new("same");
|
||||||
|
let c = LayerHash::new("diff");
|
||||||
|
assert_eq!(a, b);
|
||||||
|
assert_ne!(a, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_id_from_string() {
|
||||||
|
let s = String::from("test_id");
|
||||||
|
let id: EnvId = s.into();
|
||||||
|
assert_eq!(id.as_str(), "test_id");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue