clawft_kernel/workload_pkg/
manifest.rs1use 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
11pub const MANIFEST_SCHEMA: &str = "weftos.workload-manifest.v1";
13pub const SIGNING_DOMAIN: &[u8] = b"weftos.workload-manifest.v1\n";
15pub const KIND_COG: &str = "cog";
17pub const MANIFEST_FILE: &str = "cogpkg.json";
19pub const MAX_MANIFEST_BYTES: usize = 1024 * 1024;
21pub const MAX_SIGNATURES: usize = 16;
23pub const COG_ARCHES: &[&str] = &["aarch64", "armv7", "x86_64", "wasm"];
25
26#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
28pub enum ManifestError {
29 #[error("manifest parse: {0}")]
31 Parse(String),
32 #[error("manifest invalid: {0}")]
34 Invalid(String),
35 #[error(transparent)]
37 UnknownKind(#[from] crate::workload_kind::UnknownKind),
38 #[error(transparent)]
40 Canonical(#[from] CanonicalError),
41}
42
43fn invalid(msg: impl Into<String>) -> ManifestError {
44 ManifestError::Invalid(msg.into())
45}
46
47const MAX_ECHO_CHARS: usize = 32;
49
50fn echo(s: &str) -> String {
52 s.chars().take(MAX_ECHO_CHARS).collect()
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct SignatureEntry {
59 pub algorithm: String,
61 pub key_id: String,
63 pub public_key: String,
65 pub signature: String,
67}
68
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71#[serde(deny_unknown_fields)]
72pub struct ManifestEnvelope {
73 pub schema: String,
75 pub kind: String,
77 pub body: Value,
79 #[serde(default)]
81 pub signatures: Vec<SignatureEntry>,
82}
83
84impl ManifestEnvelope {
85 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 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 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 pub fn package_id(&self) -> Result<String, ManifestError> {
114 Ok(blake3::hash(&self.statement()?).to_hex().to_string())
115 }
116
117 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(deny_unknown_fields)]
175pub struct FileRef {
176 pub path: String,
178 pub size: u64,
180 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#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
199#[serde(deny_unknown_fields)]
200pub struct PackageSource {
201 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub repo: Option<String>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub commit: Option<String>,
207 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(deny_unknown_fields)]
235pub struct AttestationRef {
236 pub kind: String,
238 pub file: FileRef,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244#[serde(deny_unknown_fields)]
245pub struct CogPackageBody {
246 pub id: String,
248 pub version: String,
250 pub cog_toml: FileRef,
252 pub binaries: BTreeMap<String, FileRef>,
254 pub source: PackageSource,
256 #[serde(default, skip_serializing_if = "Vec::is_empty")]
258 pub attestations: Vec<AttestationRef>,
259 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
264 pub redistributable: bool,
265}
266
267impl CogPackageBody {
268 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 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
321pub 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
330pub 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
341pub 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
351pub 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}