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
2026-02-22 17:36:15 +00:00
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
|
use std::fs;
|
2026-02-23 18:07:11 +00:00
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
use std::{error::Error as StdError, fmt};
|
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
2026-02-22 17:36:15 +00:00
|
|
|
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,
|
2026-02-23 17:29:18 +00:00
|
|
|
#[error("base.image is not pinned: '{0}' (expected http(s)://...)")]
|
|
|
|
|
UnpinnedBaseImage(String),
|
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
2026-02-22 17:36:15 +00:00
|
|
|
#[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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 18:07:11 +00:00
|
|
|
#[derive(Debug)]
|
|
|
|
|
struct ManifestIoWithPath {
|
|
|
|
|
path: PathBuf,
|
|
|
|
|
source: std::io::Error,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl fmt::Display for ManifestIoWithPath {
|
|
|
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
|
|
|
write!(f, "{}: {}", self.path.display(), self.source)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl StdError for ManifestIoWithPath {
|
|
|
|
|
fn source(&self) -> Option<&(dyn StdError + 'static)> {
|
|
|
|
|
Some(&self.source)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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
2026-02-22 17:36:15 +00:00
|
|
|
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> {
|
2026-02-23 18:07:11 +00:00
|
|
|
let path = path.as_ref().to_path_buf();
|
|
|
|
|
let content = fs::read_to_string(&path).map_err(|e| {
|
|
|
|
|
let kind = e.kind();
|
|
|
|
|
ManifestError::Io(std::io::Error::new(
|
|
|
|
|
kind,
|
|
|
|
|
ManifestIoWithPath {
|
|
|
|
|
path: path.clone(),
|
|
|
|
|
source: e,
|
|
|
|
|
},
|
|
|
|
|
))
|
|
|
|
|
})?;
|
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
2026-02-22 17:36:15 +00:00
|
|
|
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());
|
|
|
|
|
}
|
|
|
|
|
}
|