use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde_json::Value;
use super::cognitum::COGNITUM_RECORD_KIND;
use super::manifest::{
AttestationRef, COG_ARCHES, CogPackageBody, FileRef, KIND_COG, MANIFEST_FILE, ManifestEnvelope,
ManifestError, PackageSource, binary_path,
};
use super::verify::MAX_FILE_BYTES;
const MAX_COG_TOML_BYTES: u64 = 256 * 1024;
pub const COGNITUM_RECORD_PATH: &str = "attestations/cognitum-release-record.json";
pub const COGNITUM_PROVENANCE_KIND: &str = "cognitum.install.provenance.v1";
pub const NO_PROVENANCE_KIND: &str = "operator.no-provenance-asserted";
pub const NO_PROVENANCE_PATH: &str = "attestations/operator-no-provenance.json";
pub const COGNITUM_PROVENANCE_PATH: &str = "attestations/cognitum-provenance.json";
const PROVENANCE_FILE: &str = "provenance.json";
const SHAREABLE_TRUST: [&str; 2] = ["ed25519-signed", "source-build"];
const TRUST_COGNITUM_SHA256: &str = "cognitum-sha256";
#[derive(Debug, Clone, Default)]
pub struct CogPackInput {
pub cog_dir: PathBuf,
pub binaries: Vec<(String, PathBuf)>,
pub source: PackageSource,
pub cognitum_record: Option<PathBuf>,
pub redistributable: bool,
pub provenance: Option<PathBuf>,
pub allow_no_provenance: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum PackError {
#[error("pack input: {0}")]
Input(String),
#[error("pack io on {path}: {msg}")]
Io {
path: String,
msg: String,
},
#[error(transparent)]
Manifest(#[from] ManifestError),
}
fn io_err(path: &Path, e: impl std::fmt::Display) -> PackError {
PackError::Io {
path: path.display().to_string(),
msg: e.to_string(),
}
}
fn read_limited(path: &Path, max: u64) -> Result<Vec<u8>, PackError> {
let meta = std::fs::metadata(path).map_err(|e| io_err(path, e))?;
if !meta.is_file() {
return Err(io_err(path, "not a regular file"));
}
if meta.len() > max {
return Err(io_err(path, format!("larger than {max} bytes")));
}
std::fs::read(path).map_err(|e| io_err(path, e))
}
pub fn cog_identity(toml_text: &str) -> Result<(String, String), PackError> {
let doc: toml::Value =
toml::from_str(toml_text).map_err(|e| PackError::Input(format!("cog.toml: {e}")))?;
let field = |k: &str| {
doc.get("cog")
.and_then(|c| c.get(k))
.and_then(toml::Value::as_str)
.map(str::to_string)
.ok_or_else(|| PackError::Input(format!("cog.toml: [cog].{k} missing")))
};
Ok((field("id")?, field("version")?))
}
fn place(out: &Path, rel: &str, content: &[u8], executable: bool) -> Result<FileRef, PackError> {
use std::io::Write;
let dest = out.join(rel);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|e| io_err(parent, e))?;
}
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(if executable { 0o755 } else { 0o644 });
}
#[cfg(not(unix))]
let _ = executable;
let mut f = opts.open(&dest).map_err(|e| io_err(&dest, e))?;
f.write_all(content).map_err(|e| io_err(&dest, e))?;
Ok(FileRef {
path: rel.to_string(),
size: content.len() as u64,
blake3: blake3::hash(content).to_hex().to_string(),
})
}
struct FoundProvenance {
beside: Option<String>,
trust: Option<String>,
sha256: Option<String>,
}
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
fn find_provenance(input: &CogPackInput) -> Result<Vec<FoundProvenance>, PackError> {
let mut candidates: Vec<(Option<String>, PathBuf)> = Vec::new();
if let Some(p) = &input.provenance {
if !p.is_file() {
return Err(PackError::Input(format!("--provenance {}: no such file", p.display())));
}
candidates.push((None, p.clone()));
}
candidates.push((None, input.cog_dir.join(PROVENANCE_FILE)));
for (arch, bin) in &input.binaries {
if let Some(dir) = bin.parent() {
candidates.push((Some(arch.clone()), dir.join(PROVENANCE_FILE)));
}
}
let mut seen = std::collections::BTreeSet::new();
let mut out = Vec::new();
for (beside, path) in candidates {
if !path.is_file() {
continue;
}
let canon = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
if !seen.insert((canon, beside.clone())) {
continue;
}
let bytes = read_limited(&path, 64 * 1024)?;
let doc: Value = serde_json::from_slice(&bytes)
.map_err(|e| PackError::Input(format!("{}: {e}", path.display())))?;
let get = |k: &str| doc.get(k).and_then(Value::as_str).map(str::to_string);
out.push(FoundProvenance { beside, trust: get("trust"), sha256: get("sha256") });
}
Ok(out)
}
#[derive(Default)]
struct ProvenanceDecision {
cognitum_stamp: Option<Vec<u8>>,
no_provenance_asserted: bool,
}
fn provenance_decision(
input: &CogPackInput,
id: &str,
version: &str,
bin_sha: &BTreeMap<String, String>,
) -> Result<ProvenanceDecision, PackError> {
let found = find_provenance(input)?;
if let Some(c) = found.iter().find(|f| f.trust.as_deref() == Some(TRUST_COGNITUM_SHA256)) {
if input.redistributable {
return Err(PackError::Input(format!(
"{PROVENANCE_FILE} marks {id}@{version} as a licensed Cognitum install \
(trust = {TRUST_COGNITUM_SHA256}); --redistributable is refused"
)));
}
let stamp = serde_json::json!({
"trust": TRUST_COGNITUM_SHA256,
"cog_id": id,
"version": version,
"sha256": c.sha256,
});
let bytes = serde_json::to_vec_pretty(&stamp)
.map_err(|e| PackError::Input(format!("{PROVENANCE_FILE}: {e}")))?;
return Ok(ProvenanceDecision { cognitum_stamp: Some(bytes), ..Default::default() });
}
if !input.redistributable {
return Ok(ProvenanceDecision::default());
}
if found.is_empty() {
if input.allow_no_provenance {
return Ok(ProvenanceDecision { no_provenance_asserted: true, ..Default::default() });
}
return Err(PackError::Input(format!(
"--redistributable needs a {PROVENANCE_FILE} (--provenance, in the cog dir, or beside a --bin) \
showing {id}@{version} is not a licensed Cognitum binary; for your own build from source \
pass --no-provenance-ok"
)));
}
for f in &found {
match f.trust.as_deref() {
Some(t) if SHAREABLE_TRUST.contains(&t) => {}
other => {
return Err(PackError::Input(format!(
"{PROVENANCE_FILE} trust {other:?} is not one of {SHAREABLE_TRUST:?}; --redistributable is refused"
)));
}
}
if f.sha256.is_none() {
return Err(PackError::Input(format!(
"{PROVENANCE_FILE} has no sha256, so it cannot show the binary is shareable; --redistributable is refused"
)));
}
}
for (arch, sha) in bin_sha {
let covered = found.iter().any(|f| {
let same = f.sha256.as_deref().is_some_and(|w| w.eq_ignore_ascii_case(sha));
same && f.beside.as_ref().is_none_or(|b| b == arch)
});
if !covered {
return Err(PackError::Input(format!(
"no {PROVENANCE_FILE} covers the {arch} binary (sha256 {sha}); --redistributable is refused"
)));
}
}
for f in &found {
if let (Some(arch), Some(want)) = (&f.beside, &f.sha256)
&& bin_sha.get(arch).is_none_or(|h| !h.eq_ignore_ascii_case(want))
{
return Err(PackError::Input(format!(
"{PROVENANCE_FILE} beside the {arch} binary does not match it; --redistributable is refused"
)));
}
}
Ok(ProvenanceDecision::default())
}
pub fn write_source_build_provenance(cog_dir: &Path, binary: &Path) -> Result<(), PackError> {
let content = read_limited(binary, MAX_FILE_BYTES)?;
let doc = serde_json::json!({ "trust": "source-build", "sha256": sha256_hex(&content) });
let path = cog_dir.join(PROVENANCE_FILE);
let bytes = serde_json::to_vec_pretty(&doc).map_err(|e| PackError::Input(e.to_string()))?;
std::fs::write(&path, bytes).map_err(|e| io_err(&path, e))
}
pub fn pack_cog(input: &CogPackInput, out_dir: &Path) -> Result<ManifestEnvelope, PackError> {
if input.binaries.is_empty() {
return Err(PackError::Input(
"at least one --bin <arch>=<path> is required".into(),
));
}
let mut seen = BTreeMap::new();
for (arch, path) in &input.binaries {
if !COG_ARCHES.contains(&arch.as_str()) {
return Err(PackError::Input(format!(
"unknown arch {arch:?} (expected one of {COG_ARCHES:?})"
)));
}
if seen.insert(arch.clone(), path.clone()).is_some() {
return Err(PackError::Input(format!("arch {arch} given twice")));
}
}
if out_dir.exists() {
let mut entries = std::fs::read_dir(out_dir).map_err(|e| io_err(out_dir, e))?;
if entries.next().is_some() {
return Err(PackError::Input(format!(
"output directory {} is not empty",
out_dir.display()
)));
}
}
std::fs::create_dir_all(out_dir).map_err(|e| io_err(out_dir, e))?;
let toml_path = input.cog_dir.join("cog.toml");
let toml_bytes = read_limited(&toml_path, MAX_COG_TOML_BYTES)?;
let toml_text = std::str::from_utf8(&toml_bytes)
.map_err(|_| PackError::Input("cog.toml is not UTF-8".into()))?;
let (id, version) = cog_identity(toml_text)?;
let cog_toml = place(out_dir, "cog.toml", &toml_bytes, false)?;
let mut binaries = BTreeMap::new();
let mut bin_sha = BTreeMap::new();
for (arch, path) in &seen {
let content = read_limited(path, MAX_FILE_BYTES)?;
bin_sha.insert(arch.clone(), sha256_hex(&content));
binaries.insert(
arch.clone(),
place(out_dir, &binary_path(arch, &id), &content, true)?,
);
}
let mut attestations = Vec::new();
if let Some(record_path) = &input.cognitum_record {
let content = read_limited(record_path, 256 * 1024)?;
serde_json::from_slice::<Value>(&content)
.map_err(|e| PackError::Input(format!("cognitum record is not JSON: {e}")))?;
attestations.push(AttestationRef {
kind: COGNITUM_RECORD_KIND.to_string(),
file: place(out_dir, COGNITUM_RECORD_PATH, &content, false)?,
});
}
let decision = provenance_decision(input, &id, &version, &bin_sha)?;
if let Some(stamp) = decision.cognitum_stamp {
attestations.push(AttestationRef {
kind: COGNITUM_PROVENANCE_KIND.to_string(),
file: place(out_dir, COGNITUM_PROVENANCE_PATH, &stamp, false)?,
});
}
if decision.no_provenance_asserted {
let note = serde_json::json!({
"asserted_by": "operator",
"flag": "--no-provenance-ok",
"cog_id": id,
"version": version,
"claim": "no provenance.json was available; the operator asserts this is not a licensed Cognitum binary",
});
let bytes = serde_json::to_vec_pretty(¬e)
.map_err(|e| PackError::Input(format!("no-provenance assertion: {e}")))?;
attestations.push(AttestationRef {
kind: NO_PROVENANCE_KIND.to_string(),
file: place(out_dir, NO_PROVENANCE_PATH, &bytes, false)?,
});
}
let body = CogPackageBody {
id,
version,
cog_toml,
binaries,
source: input.source.clone(),
attestations,
redistributable: input.redistributable,
};
body.validate()?;
let value = serde_json::to_value(&body).map_err(|e| ManifestError::Parse(e.to_string()))?;
Ok(ManifestEnvelope::new(KIND_COG, value))
}
pub fn write_manifest(out_dir: &Path, envelope: &ManifestEnvelope) -> Result<PathBuf, PackError> {
let path = out_dir.join(MANIFEST_FILE);
std::fs::write(&path, envelope.to_pretty_json()?).map_err(|e| io_err(&path, e))?;
Ok(path)
}