Skip to main content

clawft_kernel/workload_pkg/
pack.rs

1//! `cog-pack`: build a cog package directory from a cog source directory
2//! (`vendor/cogs/src/cogs/<id>`, holding the unmodified `cog.toml`) plus
3//! one binary per arch (from our fork build or upstream released binaries).
4
5use std::collections::BTreeMap;
6use std::path::{Path, PathBuf};
7
8use serde_json::Value;
9
10use super::cognitum::COGNITUM_RECORD_KIND;
11use super::manifest::{
12    AttestationRef, COG_ARCHES, CogPackageBody, FileRef, KIND_COG, MANIFEST_FILE, ManifestEnvelope,
13    ManifestError, PackageSource, binary_path,
14};
15use super::verify::MAX_FILE_BYTES;
16
17/// Largest `cog.toml` accepted.
18const MAX_COG_TOML_BYTES: u64 = 256 * 1024;
19/// Path of a packaged Cognitum release record.
20pub const COGNITUM_RECORD_PATH: &str = "attestations/cognitum-release-record.json";
21/// Attestation kind stamped when the cog dir's `provenance.json` says the
22/// binary is a licensed Cognitum install (`trust = "cognitum-sha256"`).
23/// Unlike [`COGNITUM_RECORD_KIND`] it is not a signed release record and is
24/// never parsed as one; it only marks the package Cognitum-origin.
25pub const COGNITUM_PROVENANCE_KIND: &str = "cognitum.install.provenance.v1";
26/// Attestation kind recorded when the operator packed a redistributable
27/// package with `--no-provenance-ok`, so the signed manifest shows the claim.
28pub const NO_PROVENANCE_KIND: &str = "operator.no-provenance-asserted";
29/// Path of the no-provenance assertion.
30pub const NO_PROVENANCE_PATH: &str = "attestations/operator-no-provenance.json";
31/// Path of the stamped provenance attestation.
32pub const COGNITUM_PROVENANCE_PATH: &str = "attestations/cognitum-provenance.json";
33/// `provenance.json` in an installed cog dir (written by `weaver cog install`).
34const PROVENANCE_FILE: &str = "provenance.json";
35/// `provenance.json` trust values that may back `--redistributable`: what
36/// `weaver cog install` writes for a signed weftos or private source, and an
37/// operator's own source build. Anything else is refused.
38const SHAREABLE_TRUST: [&str; 2] = ["ed25519-signed", "source-build"];
39/// `provenance.json` trust value for a licensed Cognitum binary.
40const TRUST_COGNITUM_SHA256: &str = "cognitum-sha256";
41
42/// Inputs to [`pack_cog`].
43#[derive(Debug, Clone, Default)]
44pub struct CogPackInput {
45    /// Directory holding `cog.toml`.
46    pub cog_dir: PathBuf,
47    /// `(arch, binary path)` pairs.
48    pub binaries: Vec<(String, PathBuf)>,
49    /// Provenance.
50    pub source: PackageSource,
51    /// Optional Cognitum ADR-154/155 release record to carry.
52    pub cognitum_record: Option<PathBuf>,
53    /// Sign the package as redistributable (see
54    /// [`CogPackageBody::redistributable`]). Default for a packer: false.
55    pub redistributable: bool,
56    /// Explicit `provenance.json` describing the binaries. Otherwise the cog
57    /// dir's is read, and one next to each binary (where `weaver cog install`
58    /// writes it).
59    pub provenance: Option<PathBuf>,
60    /// Allow `redistributable` with no `provenance.json` anywhere. Without it,
61    /// sharing a package needs evidence the binary is not a licensed Cognitum
62    /// one.
63    pub allow_no_provenance: bool,
64}
65
66/// Packing failure.
67#[derive(Debug, thiserror::Error)]
68pub enum PackError {
69    /// Bad inputs.
70    #[error("pack input: {0}")]
71    Input(String),
72    /// Filesystem error.
73    #[error("pack io on {path}: {msg}")]
74    Io {
75        /// Path involved.
76        path: String,
77        /// Error text.
78        msg: String,
79    },
80    /// Manifest construction failed.
81    #[error(transparent)]
82    Manifest(#[from] ManifestError),
83}
84
85fn io_err(path: &Path, e: impl std::fmt::Display) -> PackError {
86    PackError::Io {
87        path: path.display().to_string(),
88        msg: e.to_string(),
89    }
90}
91
92fn read_limited(path: &Path, max: u64) -> Result<Vec<u8>, PackError> {
93    let meta = std::fs::metadata(path).map_err(|e| io_err(path, e))?;
94    if !meta.is_file() {
95        return Err(io_err(path, "not a regular file"));
96    }
97    if meta.len() > max {
98        return Err(io_err(path, format!("larger than {max} bytes")));
99    }
100    std::fs::read(path).map_err(|e| io_err(path, e))
101}
102
103/// `[cog].id` and `[cog].version` from `cog.toml` text.
104pub fn cog_identity(toml_text: &str) -> Result<(String, String), PackError> {
105    let doc: toml::Value =
106        toml::from_str(toml_text).map_err(|e| PackError::Input(format!("cog.toml: {e}")))?;
107    let field = |k: &str| {
108        doc.get("cog")
109            .and_then(|c| c.get(k))
110            .and_then(toml::Value::as_str)
111            .map(str::to_string)
112            .ok_or_else(|| PackError::Input(format!("cog.toml: [cog].{k} missing")))
113    };
114    Ok((field("id")?, field("version")?))
115}
116
117/// Write `content` to `out/rel` (new file only) and return its [`FileRef`].
118fn place(out: &Path, rel: &str, content: &[u8], executable: bool) -> Result<FileRef, PackError> {
119    use std::io::Write;
120    let dest = out.join(rel);
121    if let Some(parent) = dest.parent() {
122        std::fs::create_dir_all(parent).map_err(|e| io_err(parent, e))?;
123    }
124    let mut opts = std::fs::OpenOptions::new();
125    opts.write(true).create_new(true);
126    #[cfg(unix)]
127    {
128        use std::os::unix::fs::OpenOptionsExt;
129        opts.mode(if executable { 0o755 } else { 0o644 });
130    }
131    #[cfg(not(unix))]
132    let _ = executable;
133    let mut f = opts.open(&dest).map_err(|e| io_err(&dest, e))?;
134    f.write_all(content).map_err(|e| io_err(&dest, e))?;
135    Ok(FileRef {
136        path: rel.to_string(),
137        size: content.len() as u64,
138        blake3: blake3::hash(content).to_hex().to_string(),
139    })
140}
141
142/// A `provenance.json` found for the package.
143struct FoundProvenance {
144    /// Arch of the `--bin` it sits next to; `None` for the cog dir's or an explicit one.
145    beside: Option<String>,
146    trust: Option<String>,
147    sha256: Option<String>,
148}
149
150fn sha256_hex(bytes: &[u8]) -> String {
151    use sha2::{Digest, Sha256};
152    Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
153}
154
155/// Every `provenance.json` that describes this package: the explicit one,
156/// the cog dir's, and one beside each binary. A file that exists but cannot
157/// be read or parsed is an error, since ignoring it could let a licensed
158/// binary be packed as shareable.
159fn find_provenance(input: &CogPackInput) -> Result<Vec<FoundProvenance>, PackError> {
160    let mut candidates: Vec<(Option<String>, PathBuf)> = Vec::new();
161    if let Some(p) = &input.provenance {
162        if !p.is_file() {
163            return Err(PackError::Input(format!("--provenance {}: no such file", p.display())));
164        }
165        candidates.push((None, p.clone()));
166    }
167    candidates.push((None, input.cog_dir.join(PROVENANCE_FILE)));
168    for (arch, bin) in &input.binaries {
169        if let Some(dir) = bin.parent() {
170            candidates.push((Some(arch.clone()), dir.join(PROVENANCE_FILE)));
171        }
172    }
173    let mut seen = std::collections::BTreeSet::new();
174    let mut out = Vec::new();
175    for (beside, path) in candidates {
176        if !path.is_file() {
177            continue;
178        }
179        let canon = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
180        // A file seen again beside a binary keeps that association.
181        if !seen.insert((canon, beside.clone())) {
182            continue;
183        }
184        let bytes = read_limited(&path, 64 * 1024)?;
185        let doc: Value = serde_json::from_slice(&bytes)
186            .map_err(|e| PackError::Input(format!("{}: {e}", path.display())))?;
187        let get = |k: &str| doc.get(k).and_then(Value::as_str).map(str::to_string);
188        out.push(FoundProvenance { beside, trust: get("trust"), sha256: get("sha256") });
189    }
190    Ok(out)
191}
192
193/// What provenance means for one pack.
194#[derive(Default)]
195struct ProvenanceDecision {
196    /// Bytes of a Cognitum stamp to carry (a provenance marks a licensed Cognitum install).
197    cognitum_stamp: Option<Vec<u8>>,
198    /// The operator relied on `--no-provenance-ok`; record it in the manifest.
199    no_provenance_asserted: bool,
200}
201
202/// Decide what provenance means for this pack. Any provenance that marks a
203/// licensed Cognitum install yields a stamp, and refuses `redistributable`.
204/// With `redistributable` and no Cognitum provenance, every `--bin` must be
205/// covered by a provenance whose trust is on [`SHAREABLE_TRUST`] and whose
206/// sha256 equals that binary's (one beside a binary covers only that binary;
207/// the cog dir's or an explicit one covers whichever binary it matches).
208/// With no provenance at all it is refused unless `allow_no_provenance`, which
209/// is recorded.
210fn provenance_decision(
211    input: &CogPackInput,
212    id: &str,
213    version: &str,
214    bin_sha: &BTreeMap<String, String>,
215) -> Result<ProvenanceDecision, PackError> {
216    let found = find_provenance(input)?;
217    if let Some(c) = found.iter().find(|f| f.trust.as_deref() == Some(TRUST_COGNITUM_SHA256)) {
218        if input.redistributable {
219            return Err(PackError::Input(format!(
220                "{PROVENANCE_FILE} marks {id}@{version} as a licensed Cognitum install \
221                 (trust = {TRUST_COGNITUM_SHA256}); --redistributable is refused"
222            )));
223        }
224        let stamp = serde_json::json!({
225            "trust": TRUST_COGNITUM_SHA256,
226            "cog_id": id,
227            "version": version,
228            "sha256": c.sha256,
229        });
230        let bytes = serde_json::to_vec_pretty(&stamp)
231            .map_err(|e| PackError::Input(format!("{PROVENANCE_FILE}: {e}")))?;
232        return Ok(ProvenanceDecision { cognitum_stamp: Some(bytes), ..Default::default() });
233    }
234    if !input.redistributable {
235        return Ok(ProvenanceDecision::default());
236    }
237    if found.is_empty() {
238        if input.allow_no_provenance {
239            return Ok(ProvenanceDecision { no_provenance_asserted: true, ..Default::default() });
240        }
241        return Err(PackError::Input(format!(
242            "--redistributable needs a {PROVENANCE_FILE} (--provenance, in the cog dir, or beside a --bin) \
243             showing {id}@{version} is not a licensed Cognitum binary; for your own build from source \
244             pass --no-provenance-ok"
245        )));
246    }
247    for f in &found {
248        match f.trust.as_deref() {
249            Some(t) if SHAREABLE_TRUST.contains(&t) => {}
250            other => {
251                return Err(PackError::Input(format!(
252                    "{PROVENANCE_FILE} trust {other:?} is not one of {SHAREABLE_TRUST:?}; --redistributable is refused"
253                )));
254            }
255        }
256        if f.sha256.is_none() {
257            return Err(PackError::Input(format!(
258                "{PROVENANCE_FILE} has no sha256, so it cannot show the binary is shareable; --redistributable is refused"
259            )));
260        }
261    }
262    for (arch, sha) in bin_sha {
263        let covered = found.iter().any(|f| {
264            let same = f.sha256.as_deref().is_some_and(|w| w.eq_ignore_ascii_case(sha));
265            same && f.beside.as_ref().is_none_or(|b| b == arch)
266        });
267        if !covered {
268            return Err(PackError::Input(format!(
269                "no {PROVENANCE_FILE} covers the {arch} binary (sha256 {sha}); --redistributable is refused"
270            )));
271        }
272    }
273    // A provenance beside a binary must also describe that binary.
274    for f in &found {
275        if let (Some(arch), Some(want)) = (&f.beside, &f.sha256)
276            && bin_sha.get(arch).is_none_or(|h| !h.eq_ignore_ascii_case(want))
277        {
278            return Err(PackError::Input(format!(
279                "{PROVENANCE_FILE} beside the {arch} binary does not match it; --redistributable is refused"
280            )));
281        }
282    }
283    Ok(ProvenanceDecision::default())
284}
285
286/// Record an operator's own source build in `<cog_dir>/provenance.json`
287/// (`trust = "source-build"`, with the sha256 of `binary`), which is what
288/// `pack --redistributable` accepts as evidence the binary is not a licensed
289/// Cognitum one. One provenance describes one binary.
290pub fn write_source_build_provenance(cog_dir: &Path, binary: &Path) -> Result<(), PackError> {
291    let content = read_limited(binary, MAX_FILE_BYTES)?;
292    let doc = serde_json::json!({ "trust": "source-build", "sha256": sha256_hex(&content) });
293    let path = cog_dir.join(PROVENANCE_FILE);
294    let bytes = serde_json::to_vec_pretty(&doc).map_err(|e| PackError::Input(e.to_string()))?;
295    std::fs::write(&path, bytes).map_err(|e| io_err(&path, e))
296}
297
298/// Build an unsigned cog package in `out_dir` (which must not exist or be
299/// empty) and return its envelope. Sign it with
300/// [`super::sign::sign_envelope`] and persist with [`write_manifest`].
301pub fn pack_cog(input: &CogPackInput, out_dir: &Path) -> Result<ManifestEnvelope, PackError> {
302    if input.binaries.is_empty() {
303        return Err(PackError::Input(
304            "at least one --bin <arch>=<path> is required".into(),
305        ));
306    }
307    let mut seen = BTreeMap::new();
308    for (arch, path) in &input.binaries {
309        if !COG_ARCHES.contains(&arch.as_str()) {
310            return Err(PackError::Input(format!(
311                "unknown arch {arch:?} (expected one of {COG_ARCHES:?})"
312            )));
313        }
314        if seen.insert(arch.clone(), path.clone()).is_some() {
315            return Err(PackError::Input(format!("arch {arch} given twice")));
316        }
317    }
318    if out_dir.exists() {
319        let mut entries = std::fs::read_dir(out_dir).map_err(|e| io_err(out_dir, e))?;
320        if entries.next().is_some() {
321            return Err(PackError::Input(format!(
322                "output directory {} is not empty",
323                out_dir.display()
324            )));
325        }
326    }
327    std::fs::create_dir_all(out_dir).map_err(|e| io_err(out_dir, e))?;
328
329    let toml_path = input.cog_dir.join("cog.toml");
330    let toml_bytes = read_limited(&toml_path, MAX_COG_TOML_BYTES)?;
331    let toml_text = std::str::from_utf8(&toml_bytes)
332        .map_err(|_| PackError::Input("cog.toml is not UTF-8".into()))?;
333    let (id, version) = cog_identity(toml_text)?;
334    let cog_toml = place(out_dir, "cog.toml", &toml_bytes, false)?;
335
336    let mut binaries = BTreeMap::new();
337    let mut bin_sha = BTreeMap::new();
338    for (arch, path) in &seen {
339        let content = read_limited(path, MAX_FILE_BYTES)?;
340        bin_sha.insert(arch.clone(), sha256_hex(&content));
341        binaries.insert(
342            arch.clone(),
343            place(out_dir, &binary_path(arch, &id), &content, true)?,
344        );
345    }
346
347    let mut attestations = Vec::new();
348    if let Some(record_path) = &input.cognitum_record {
349        let content = read_limited(record_path, 256 * 1024)?;
350        serde_json::from_slice::<Value>(&content)
351            .map_err(|e| PackError::Input(format!("cognitum record is not JSON: {e}")))?;
352        attestations.push(AttestationRef {
353            kind: COGNITUM_RECORD_KIND.to_string(),
354            file: place(out_dir, COGNITUM_RECORD_PATH, &content, false)?,
355        });
356    }
357
358    let decision = provenance_decision(input, &id, &version, &bin_sha)?;
359    if let Some(stamp) = decision.cognitum_stamp {
360        attestations.push(AttestationRef {
361            kind: COGNITUM_PROVENANCE_KIND.to_string(),
362            file: place(out_dir, COGNITUM_PROVENANCE_PATH, &stamp, false)?,
363        });
364    }
365    if decision.no_provenance_asserted {
366        let note = serde_json::json!({
367            "asserted_by": "operator",
368            "flag": "--no-provenance-ok",
369            "cog_id": id,
370            "version": version,
371            "claim": "no provenance.json was available; the operator asserts this is not a licensed Cognitum binary",
372        });
373        let bytes = serde_json::to_vec_pretty(&note)
374            .map_err(|e| PackError::Input(format!("no-provenance assertion: {e}")))?;
375        attestations.push(AttestationRef {
376            kind: NO_PROVENANCE_KIND.to_string(),
377            file: place(out_dir, NO_PROVENANCE_PATH, &bytes, false)?,
378        });
379    }
380
381    let body = CogPackageBody {
382        id,
383        version,
384        cog_toml,
385        binaries,
386        source: input.source.clone(),
387        attestations,
388        redistributable: input.redistributable,
389    };
390    body.validate()?;
391    let value = serde_json::to_value(&body).map_err(|e| ManifestError::Parse(e.to_string()))?;
392    Ok(ManifestEnvelope::new(KIND_COG, value))
393}
394
395/// Write (or overwrite) `out_dir/cogpkg.json`.
396pub fn write_manifest(out_dir: &Path, envelope: &ManifestEnvelope) -> Result<PathBuf, PackError> {
397    let path = out_dir.join(MANIFEST_FILE);
398    std::fs::write(&path, envelope.to_pretty_json()?).map_err(|e| io_err(&path, e))?;
399    Ok(path)
400}