use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::canonical::{CanonicalError, canonical_json};
use super::codec::is_lower_hex;
pub const MANIFEST_SCHEMA: &str = "weftos.workload-manifest.v1";
pub const SIGNING_DOMAIN: &[u8] = b"weftos.workload-manifest.v1\n";
pub const KIND_COG: &str = "cog";
pub const MANIFEST_FILE: &str = "cogpkg.json";
pub const MAX_MANIFEST_BYTES: usize = 1024 * 1024;
pub const MAX_SIGNATURES: usize = 16;
pub const COG_ARCHES: &[&str] = &["aarch64", "armv7", "x86_64", "wasm"];
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ManifestError {
#[error("manifest parse: {0}")]
Parse(String),
#[error("manifest invalid: {0}")]
Invalid(String),
#[error(transparent)]
UnknownKind(#[from] crate::workload_kind::UnknownKind),
#[error(transparent)]
Canonical(#[from] CanonicalError),
}
fn invalid(msg: impl Into<String>) -> ManifestError {
ManifestError::Invalid(msg.into())
}
const MAX_ECHO_CHARS: usize = 32;
fn echo(s: &str) -> String {
s.chars().take(MAX_ECHO_CHARS).collect()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SignatureEntry {
pub algorithm: String,
pub key_id: String,
pub public_key: String,
pub signature: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestEnvelope {
pub schema: String,
pub kind: String,
pub body: Value,
#[serde(default)]
pub signatures: Vec<SignatureEntry>,
}
impl ManifestEnvelope {
pub fn new(kind: &str, body: Value) -> Self {
Self {
schema: MANIFEST_SCHEMA.to_string(),
kind: kind.to_string(),
body,
signatures: Vec::new(),
}
}
pub fn statement(&self) -> Result<Vec<u8>, ManifestError> {
Ok(canonical_json(&json!({
"schema": self.schema,
"kind": self.kind,
"body": self.body,
}))?)
}
pub fn signed_statement(&self) -> Result<Vec<u8>, ManifestError> {
let mut msg = SIGNING_DOMAIN.to_vec();
msg.extend_from_slice(&self.statement()?);
Ok(msg)
}
pub fn package_id(&self) -> Result<String, ManifestError> {
Ok(blake3::hash(&self.statement()?).to_hex().to_string())
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, ManifestError> {
if bytes.len() > MAX_MANIFEST_BYTES {
return Err(invalid(format!(
"manifest exceeds {MAX_MANIFEST_BYTES} bytes"
)));
}
let env: Self =
serde_json::from_slice(bytes).map_err(|e| ManifestError::Parse(e.to_string()))?;
if env.schema != MANIFEST_SCHEMA {
return Err(invalid(format!("unknown schema {:?}", echo(&env.schema))));
}
if !valid_token(&env.kind, 32) {
return Err(invalid(format!("bad kind {:?}", echo(&env.kind))));
}
if env.signatures.len() > MAX_SIGNATURES {
return Err(invalid(format!("more than {MAX_SIGNATURES} signatures")));
}
if let Some(s) = env.signatures.iter().find(|s| !valid_token(&s.key_id, 128)) {
return Err(invalid(format!(
"bad signature key id {:?}",
s.key_id.chars().take(40).collect::<String>()
)));
}
Ok(env)
}
pub fn to_pretty_json(&self) -> Result<Vec<u8>, ManifestError> {
let mut out =
serde_json::to_vec_pretty(self).map_err(|e| ManifestError::Parse(e.to_string()))?;
out.push(b'\n');
Ok(out)
}
pub fn cog_body(&self) -> Result<CogPackageBody, ManifestError> {
if self.kind != KIND_COG {
return Err(invalid(format!("kind {:?} is not a cog", self.kind)));
}
self.cog_shaped_body()
}
pub fn cog_shaped_body(&self) -> Result<CogPackageBody, ManifestError> {
let body: CogPackageBody = serde_json::from_value(self.body.clone())
.map_err(|e| ManifestError::Parse(format!("cog body: {e}")))?;
body.validate()?;
Ok(body)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FileRef {
pub path: String,
pub size: u64,
pub blake3: String,
}
impl FileRef {
fn validate(&self) -> Result<(), ManifestError> {
validate_rel_path(&self.path)?;
if !is_lower_hex(&self.blake3, 64) {
return Err(invalid(format!(
"{}: blake3 must be 64 lower-case hex",
self.path
)));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PackageSource {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub release_url: Option<String>,
}
impl PackageSource {
fn validate(&self) -> Result<(), ManifestError> {
if self.commit.is_none() && self.release_url.is_none() {
return Err(invalid("source needs a commit or a release_url"));
}
if let Some(c) = &self.commit
&& !(c.len() >= 7 && c.len() <= 64 && is_lower_hex(c, c.len()))
{
return Err(invalid("source.commit must be 7..64 lower-case hex"));
}
for s in [&self.repo, &self.release_url].into_iter().flatten() {
if s.is_empty() || s.len() > 512 || s.chars().any(|c| c.is_control()) {
return Err(invalid("source strings must be 1..512 printable chars"));
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AttestationRef {
pub kind: String,
pub file: FileRef,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CogPackageBody {
pub id: String,
pub version: String,
pub cog_toml: FileRef,
pub binaries: BTreeMap<String, FileRef>,
pub source: PackageSource,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attestations: Vec<AttestationRef>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub redistributable: bool,
}
impl CogPackageBody {
pub fn validate(&self) -> Result<(), ManifestError> {
if !valid_cog_id(&self.id) {
return Err(invalid(format!("bad cog id {:?}", self.id)));
}
if !valid_token(&self.version, 64) {
return Err(invalid(format!("bad version {:?}", self.version)));
}
self.cog_toml.validate()?;
if self.cog_toml.path != "cog.toml" {
return Err(invalid("cog_toml.path must be cog.toml"));
}
if self.binaries.is_empty() {
return Err(invalid("a cog package needs at least one binary"));
}
for (arch, f) in &self.binaries {
if !COG_ARCHES.contains(&arch.as_str()) {
return Err(invalid(format!("unknown arch {arch:?}")));
}
f.validate()?;
if f.path != binary_path(arch, &self.id) {
return Err(invalid(format!(
"{arch} binary must be at {}",
binary_path(arch, &self.id)
)));
}
}
for a in &self.attestations {
if !valid_token(&a.kind, 64) {
return Err(invalid(format!("bad attestation kind {:?}", a.kind)));
}
a.file.validate()?;
if !a.file.path.starts_with("attestations/") {
return Err(invalid("attestation files live under attestations/"));
}
}
self.source.validate()?;
let mut paths: Vec<&str> = self.files().map(|f| f.path.as_str()).collect();
paths.sort_unstable();
if paths.windows(2).any(|w| w[0] == w[1]) {
return Err(invalid("duplicate file path"));
}
Ok(())
}
pub fn files(&self) -> impl Iterator<Item = &FileRef> {
std::iter::once(&self.cog_toml)
.chain(self.binaries.values())
.chain(self.attestations.iter().map(|a| &a.file))
}
}
pub fn binary_path(arch: &str, id: &str) -> String {
if arch == "wasm" {
format!("wasm/{id}.wasm")
} else {
format!("{arch}/cog-{id}")
}
}
pub fn valid_cog_id(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 64
&& !s.starts_with('-')
&& !s.ends_with('-')
&& !s.contains("--")
&& s.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
pub fn valid_token(s: &str, max: usize) -> bool {
let b = s.as_bytes();
!b.is_empty()
&& b.len() <= max
&& b[0].is_ascii_alphanumeric()
&& b.iter()
.all(|c| c.is_ascii_alphanumeric() || b"._+:-".contains(c))
}
pub fn validate_rel_path(p: &str) -> Result<(), ManifestError> {
let ok = !p.is_empty()
&& p.len() <= 256
&& !p.starts_with('/')
&& p.split('/')
.all(|c| !c.is_empty() && c != "." && c != ".." && valid_token(c, 128));
if ok {
Ok(())
} else {
Err(invalid(format!("unsafe path {p:?}")))
}
}