Skip to main content

clawft_kernel/workload_pkg/
manifest.rs

1//! Manifest envelope and the cog package body (ADR-100 section 1).
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6use serde_json::{Value, json};
7
8use super::canonical::{CanonicalError, canonical_json};
9use super::codec::is_lower_hex;
10
11/// Envelope schema identifier. Shared by every workload kind.
12pub const MANIFEST_SCHEMA: &str = "weftos.workload-manifest.v1";
13/// Domain-separation prefix for signatures over the envelope statement.
14pub const SIGNING_DOMAIN: &[u8] = b"weftos.workload-manifest.v1\n";
15/// Workload kind for Cognitum cogs.
16pub const KIND_COG: &str = "cog";
17/// Manifest file name inside a cog package directory.
18pub const MANIFEST_FILE: &str = "cogpkg.json";
19/// Largest manifest file accepted.
20pub const MAX_MANIFEST_BYTES: usize = 1024 * 1024;
21/// Most signature entries accepted on one envelope.
22pub const MAX_SIGNATURES: usize = 16;
23/// Architectures a cog package may carry (ADR-100 section 1).
24pub const COG_ARCHES: &[&str] = &["aarch64", "armv7", "x86_64", "wasm"];
25
26/// Manifest parse / validation failure.
27#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
28pub enum ManifestError {
29    /// JSON could not be parsed into the expected shape.
30    #[error("manifest parse: {0}")]
31    Parse(String),
32    /// A field failed validation.
33    #[error("manifest invalid: {0}")]
34    Invalid(String),
35    /// No workload kind is registered under the manifest's `kind`.
36    #[error(transparent)]
37    UnknownKind(#[from] crate::workload_kind::UnknownKind),
38    /// Canonical encoding refused the value.
39    #[error(transparent)]
40    Canonical(#[from] CanonicalError),
41}
42
43fn invalid(msg: impl Into<String>) -> ManifestError {
44    ManifestError::Invalid(msg.into())
45}
46
47/// Longest unauthenticated manifest text echoed into an error.
48const MAX_ECHO_CHARS: usize = 32;
49
50/// `s` cut to [`MAX_ECHO_CHARS`] characters, for error text.
51fn echo(s: &str) -> String {
52    s.chars().take(MAX_ECHO_CHARS).collect()
53}
54
55/// One Ed25519 signature over [`ManifestEnvelope::signed_statement`].
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct SignatureEntry {
59    /// Always `"ed25519"` in v1 (ML-DSA-65 dual signing is a follow-up).
60    pub algorithm: String,
61    /// Signer key id; must equal the pinned entry for `public_key`.
62    pub key_id: String,
63    /// 32-byte public key, lower-case hex.
64    pub public_key: String,
65    /// 64-byte signature, lower-case hex.
66    pub signature: String,
67}
68
69/// Kind-agnostic signed manifest envelope.
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71#[serde(deny_unknown_fields)]
72pub struct ManifestEnvelope {
73    /// Must be [`MANIFEST_SCHEMA`].
74    pub schema: String,
75    /// Workload kind, e.g. [`KIND_COG`].
76    pub kind: String,
77    /// Kind-specific body.
78    pub body: Value,
79    /// Detached signatures over the statement (schema, kind, body).
80    #[serde(default)]
81    pub signatures: Vec<SignatureEntry>,
82}
83
84impl ManifestEnvelope {
85    /// New unsigned envelope.
86    pub fn new(kind: &str, body: Value) -> Self {
87        Self {
88            schema: MANIFEST_SCHEMA.to_string(),
89            kind: kind.to_string(),
90            body,
91            signatures: Vec::new(),
92        }
93    }
94
95    /// Canonical statement: `{"body":..,"kind":..,"schema":..}`. Signatures
96    /// are excluded so they can be added independently.
97    pub fn statement(&self) -> Result<Vec<u8>, ManifestError> {
98        Ok(canonical_json(&json!({
99            "schema": self.schema,
100            "kind": self.kind,
101            "body": self.body,
102        }))?)
103    }
104
105    /// Bytes an Ed25519 signer signs: [`SIGNING_DOMAIN`] then the statement.
106    pub fn signed_statement(&self) -> Result<Vec<u8>, ManifestError> {
107        let mut msg = SIGNING_DOMAIN.to_vec();
108        msg.extend_from_slice(&self.statement()?);
109        Ok(msg)
110    }
111
112    /// Package id: BLAKE3 hex of the statement. Stable across re-signing.
113    pub fn package_id(&self) -> Result<String, ManifestError> {
114        Ok(blake3::hash(&self.statement()?).to_hex().to_string())
115    }
116
117    /// Parse and validate the envelope frame (not the body).
118    pub fn from_bytes(bytes: &[u8]) -> Result<Self, ManifestError> {
119        if bytes.len() > MAX_MANIFEST_BYTES {
120            return Err(invalid(format!(
121                "manifest exceeds {MAX_MANIFEST_BYTES} bytes"
122            )));
123        }
124        let env: Self =
125            serde_json::from_slice(bytes).map_err(|e| ManifestError::Parse(e.to_string()))?;
126        if env.schema != MANIFEST_SCHEMA {
127            return Err(invalid(format!("unknown schema {:?}", echo(&env.schema))));
128        }
129        if !valid_token(&env.kind, 32) {
130            return Err(invalid(format!("bad kind {:?}", echo(&env.kind))));
131        }
132        if env.signatures.len() > MAX_SIGNATURES {
133            return Err(invalid(format!("more than {MAX_SIGNATURES} signatures")));
134        }
135        if let Some(s) = env.signatures.iter().find(|s| !valid_token(&s.key_id, 128)) {
136            return Err(invalid(format!(
137                "bad signature key id {:?}",
138                s.key_id.chars().take(40).collect::<String>()
139            )));
140        }
141        Ok(env)
142    }
143
144    /// Pretty JSON with a trailing newline, for writing `cogpkg.json`.
145    pub fn to_pretty_json(&self) -> Result<Vec<u8>, ManifestError> {
146        let mut out =
147            serde_json::to_vec_pretty(self).map_err(|e| ManifestError::Parse(e.to_string()))?;
148        out.push(b'\n');
149        Ok(out)
150    }
151
152    /// Typed cog body, validated. Fails if `kind != "cog"`.
153    pub fn cog_body(&self) -> Result<CogPackageBody, ManifestError> {
154        if self.kind != KIND_COG {
155            return Err(invalid(format!("kind {:?} is not a cog", self.kind)));
156        }
157        self.cog_shaped_body()
158    }
159
160    /// The body parsed and validated as a cog package body, whatever the
161    /// envelope's `kind`. A registered kind whose verified package reuses
162    /// the cog body shape (the only shape `VerifiedPackage` carries today)
163    /// goes through this after its own `validate`.
164    pub fn cog_shaped_body(&self) -> Result<CogPackageBody, ManifestError> {
165        let body: CogPackageBody = serde_json::from_value(self.body.clone())
166            .map_err(|e| ManifestError::Parse(format!("cog body: {e}")))?;
167        body.validate()?;
168        Ok(body)
169    }
170}
171
172/// A file inside the package, pinned by BLAKE3 and size.
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(deny_unknown_fields)]
175pub struct FileRef {
176    /// Relative path inside the package directory (forward slashes).
177    pub path: String,
178    /// Size in bytes.
179    pub size: u64,
180    /// BLAKE3 hex of the content; also its `ArtifactStore` key.
181    pub blake3: String,
182}
183
184impl FileRef {
185    fn validate(&self) -> Result<(), ManifestError> {
186        validate_rel_path(&self.path)?;
187        if !is_lower_hex(&self.blake3, 64) {
188            return Err(invalid(format!(
189                "{}: blake3 must be 64 lower-case hex",
190                self.path
191            )));
192        }
193        Ok(())
194    }
195}
196
197/// Where the package content came from (ADR-100 section 6.3).
198#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
199#[serde(deny_unknown_fields)]
200pub struct PackageSource {
201    /// Source repository (for example the cogs fork).
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub repo: Option<String>,
204    /// Git commit the binaries were built from.
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub commit: Option<String>,
207    /// Upstream release URL, when packaging released binaries. A URL is
208    /// provenance, never trust: the operator signature is the trust.
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub release_url: Option<String>,
211}
212
213impl PackageSource {
214    fn validate(&self) -> Result<(), ManifestError> {
215        if self.commit.is_none() && self.release_url.is_none() {
216            return Err(invalid("source needs a commit or a release_url"));
217        }
218        if let Some(c) = &self.commit
219            && !(c.len() >= 7 && c.len() <= 64 && is_lower_hex(c, c.len()))
220        {
221            return Err(invalid("source.commit must be 7..64 lower-case hex"));
222        }
223        for s in [&self.repo, &self.release_url].into_iter().flatten() {
224            if s.is_empty() || s.len() > 512 || s.chars().any(|c| c.is_control()) {
225                return Err(invalid("source strings must be 1..512 printable chars"));
226            }
227        }
228        Ok(())
229    }
230}
231
232/// An external attestation file carried in the package.
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(deny_unknown_fields)]
235pub struct AttestationRef {
236    /// Attestation kind, e.g. `cognitum.cog.release-record.v1`.
237    pub kind: String,
238    /// The file.
239    pub file: FileRef,
240}
241
242/// Body of a `kind = "cog"` manifest.
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244#[serde(deny_unknown_fields)]
245pub struct CogPackageBody {
246    /// Cog id from `[cog].id`.
247    pub id: String,
248    /// Cog version from `[cog].version`.
249    pub version: String,
250    /// The unmodified `cog.toml`.
251    pub cog_toml: FileRef,
252    /// One binary per arch, keyed by arch name.
253    pub binaries: BTreeMap<String, FileRef>,
254    /// Provenance.
255    pub source: PackageSource,
256    /// Optional external attestations.
257    #[serde(default, skip_serializing_if = "Vec::is_empty")]
258    pub attestations: Vec<AttestationRef>,
259    /// The signer states this package may be handed to other nodes (seeded,
260    /// advertised, served over the swarm). Absent means false: sharing is
261    /// opt-in, so an operator re-pack of a licence-gated binary is never
262    /// redistributed by accident. It is part of the signed statement.
263    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
264    pub redistributable: bool,
265}
266
267impl CogPackageBody {
268    /// Validate ids, paths and layout.
269    pub fn validate(&self) -> Result<(), ManifestError> {
270        if !valid_cog_id(&self.id) {
271            return Err(invalid(format!("bad cog id {:?}", self.id)));
272        }
273        if !valid_token(&self.version, 64) {
274            return Err(invalid(format!("bad version {:?}", self.version)));
275        }
276        self.cog_toml.validate()?;
277        if self.cog_toml.path != "cog.toml" {
278            return Err(invalid("cog_toml.path must be cog.toml"));
279        }
280        if self.binaries.is_empty() {
281            return Err(invalid("a cog package needs at least one binary"));
282        }
283        for (arch, f) in &self.binaries {
284            if !COG_ARCHES.contains(&arch.as_str()) {
285                return Err(invalid(format!("unknown arch {arch:?}")));
286            }
287            f.validate()?;
288            if f.path != binary_path(arch, &self.id) {
289                return Err(invalid(format!(
290                    "{arch} binary must be at {}",
291                    binary_path(arch, &self.id)
292                )));
293            }
294        }
295        for a in &self.attestations {
296            if !valid_token(&a.kind, 64) {
297                return Err(invalid(format!("bad attestation kind {:?}", a.kind)));
298            }
299            a.file.validate()?;
300            if !a.file.path.starts_with("attestations/") {
301                return Err(invalid("attestation files live under attestations/"));
302            }
303        }
304        self.source.validate()?;
305        let mut paths: Vec<&str> = self.files().map(|f| f.path.as_str()).collect();
306        paths.sort_unstable();
307        if paths.windows(2).any(|w| w[0] == w[1]) {
308            return Err(invalid("duplicate file path"));
309        }
310        Ok(())
311    }
312
313    /// Every file the package lists.
314    pub fn files(&self) -> impl Iterator<Item = &FileRef> {
315        std::iter::once(&self.cog_toml)
316            .chain(self.binaries.values())
317            .chain(self.attestations.iter().map(|a| &a.file))
318    }
319}
320
321/// Canonical in-package path of a cog binary for `arch`.
322pub fn binary_path(arch: &str, id: &str) -> String {
323    if arch == "wasm" {
324        format!("wasm/{id}.wasm")
325    } else {
326        format!("{arch}/cog-{id}")
327    }
328}
329
330/// Cog ids: lower-case alphanumerics separated by single hyphens.
331pub fn valid_cog_id(s: &str) -> bool {
332    !s.is_empty()
333        && s.len() <= 64
334        && !s.starts_with('-')
335        && !s.ends_with('-')
336        && !s.contains("--")
337        && s.bytes()
338            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
339}
340
341/// Printable token: `[A-Za-z0-9][A-Za-z0-9._+:-]*`, bounded.
342pub fn valid_token(s: &str, max: usize) -> bool {
343    let b = s.as_bytes();
344    !b.is_empty()
345        && b.len() <= max
346        && b[0].is_ascii_alphanumeric()
347        && b.iter()
348            .all(|c| c.is_ascii_alphanumeric() || b"._+:-".contains(c))
349}
350
351/// Relative, normalized, forward-slash path with no traversal.
352pub fn validate_rel_path(p: &str) -> Result<(), ManifestError> {
353    let ok = !p.is_empty()
354        && p.len() <= 256
355        && !p.starts_with('/')
356        && p.split('/')
357            .all(|c| !c.is_empty() && c != "." && c != ".." && valid_token(c, 128));
358    if ok {
359        Ok(())
360    } else {
361        Err(invalid(format!("unsafe path {p:?}")))
362    }
363}