1use std::collections::BTreeSet;
27use std::fmt;
28
29#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
31#[serde(deny_unknown_fields)]
32pub struct VarvePin {
33 pub version: String,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct ManifestRealm {
40 pub name: String,
41 pub channel: String,
42 pub registry: String,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Layout {
48 Tarball,
50 RawPerPlatform,
52}
53
54impl Layout {
55 fn parse(s: &str) -> Option<Layout> {
56 match s {
57 "tarball" => Some(Layout::Tarball),
58 "raw-per-platform" => Some(Layout::RawPerPlatform),
59 _ => None,
60 }
61 }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct ManifestTool {
67 pub name: String,
68 pub version: String,
69 #[serde(default)]
71 pub repo: Option<String>,
72 #[serde(default)]
75 pub binary: Option<String>,
76 #[serde(default)]
79 pub asset: Option<String>,
80 #[serde(default)]
82 pub layout: Option<String>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct ManifestVsix {
88 pub name: String,
89 pub version: String,
90 #[serde(default)]
91 pub repo: Option<String>,
92 pub asset: String,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct LayerManifest {
104 pub varve: VarvePin,
105 pub realm: ManifestRealm,
106 #[serde(default, rename = "tool")]
107 pub tools: Vec<ManifestTool>,
108 #[serde(default, rename = "vsix")]
109 pub vsix: Vec<ManifestVsix>,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub enum LayerSpecError {
114 Parse(String),
116 Unencodable {
118 field: String,
119 value: String,
120 why: &'static str,
121 },
122 UnknownLayout { tool: String, layout: String },
124 ManyRawPerPlatform { first: String, second: String },
127 VsixForeignOwner { name: String, repo: String },
129 RepoNameMismatch {
132 name: String,
133 repo: String,
134 basename: String,
135 },
136 Duplicate { kind: &'static str, name: String },
138 Empty,
140}
141
142impl fmt::Display for LayerSpecError {
143 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144 match self {
145 LayerSpecError::Parse(e) => write!(f, "layer.toml does not parse: {e}"),
146 LayerSpecError::Unencodable { field, value, why } => write!(
147 f,
148 "{field} = {value:?} cannot be passed to the assembler: {why}. \
149 The assembler reads space-separated entries of colon-separated \
150 fields, so such a value would be split or truncated silently \
151 and the layer would carry the wrong bytes under a good signature."
152 ),
153 LayerSpecError::UnknownLayout { tool, layout } => write!(
154 f,
155 "tool {tool:?} declares layout = {layout:?}, which varve does not \
156 implement. Use \"tarball\" (one .tar.gz per target triple) or \
157 \"raw-per-platform\" (bare per-platform binaries)."
158 ),
159 LayerSpecError::ManyRawPerPlatform { first, second } => write!(
160 f,
161 "tools {first:?} and {second:?} both declare \
162 layout = \"raw-per-platform\", and the assembler carries only \
163 one (as WSC_VERSION). Depositing would silently drop one of \
164 them. Teach the assembler a general raw-per-platform list \
165 before adding the second."
166 ),
167 LayerSpecError::VsixForeignOwner { name, repo } => write!(
168 f,
169 "vsix {name:?} names repo {repo:?}, but the assembler resolves \
170 extension repositories as pulseengine/<name> and would fetch \
171 the wrong release. Either publish it under pulseengine, or \
172 teach the assembler an owner field for extensions."
173 ),
174 LayerSpecError::RepoNameMismatch {
175 name,
176 repo,
177 basename,
178 } => write!(
179 f,
180 "tool {name:?} names repo {repo:?}, but the assembler takes a \
181 tarball tool's identity from the repository basename — it \
182 would download, name the payload, and default the asset \
183 template as {basename:?}, and {name:?} would mean nothing. A \
184 consumer asking for {name:?} would then find no such tool in \
185 a layer that deposited and verified. Rename the entry to \
186 {basename:?}, or set `binary` if only the executable differs."
187 ),
188 LayerSpecError::Duplicate { kind, name } => {
189 write!(f, "two {kind} entries are both named {name:?}")
190 }
191 LayerSpecError::Empty => write!(
192 f,
193 "layer.toml declares no [[tool]] and no [[vsix]]: there is \
194 nothing to deposit. A layer with no payloads is signed, \
195 published, and useless."
196 ),
197 }
198 }
199}
200
201impl std::error::Error for LayerSpecError {}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct AssemblerEnv {
210 pub layer_tools: String,
211 pub wsc_version: Option<String>,
212 pub vsix_packages: String,
213 pub realm: String,
214 pub channel: String,
215 pub registry: String,
216 pub varve_version: String,
217}
218
219impl AssemblerEnv {
220 pub fn render(&self) -> String {
222 let mut out = String::new();
223 out.push_str(&format!("TARBALL_TOOLS={}\n", self.layer_tools));
224 out.push_str(&format!(
225 "WSC_VERSION={}\n",
226 self.wsc_version.as_deref().unwrap_or("")
227 ));
228 out.push_str(&format!("VSIX_PACKAGES={}\n", self.vsix_packages));
229 out.push_str(&format!("VARVE_REALM={}\n", self.realm));
230 out.push_str(&format!("VARVE_CHANNEL={}\n", self.channel));
231 out.push_str(&format!("VARVE_REGISTRY={}\n", self.registry));
232 out.push_str(&format!("VARVE_VERSION={}\n", self.varve_version));
233 out
234 }
235}
236
237pub fn parse_layer_manifest(text: &str) -> Result<LayerManifest, LayerSpecError> {
238 toml::from_str(text).map_err(|e| LayerSpecError::Parse(e.to_string()))
239}
240
241fn encodable(field: &str, value: &str) -> Result<(), LayerSpecError> {
247 let unencodable = |why| LayerSpecError::Unencodable {
248 field: field.to_string(),
249 value: value.to_string(),
250 why,
251 };
252 if value.is_empty() {
253 return Err(unencodable("it is empty"));
254 }
255 if value.contains(':') {
256 return Err(unencodable("it contains ':', which separates fields"));
257 }
258 if value.chars().any(char::is_whitespace) {
259 return Err(unencodable(
260 "it contains whitespace, which separates entries",
261 ));
262 }
263 Ok(())
264}
265
266fn split_repo(repo: &str, default_name: &str) -> (String, String) {
268 match repo.split_once('/') {
269 Some((owner, name)) => (owner.to_string(), name.to_string()),
270 None => ("pulseengine".to_string(), default_name.to_string()),
271 }
272}
273
274pub fn assembler_env(m: &LayerManifest) -> Result<AssemblerEnv, LayerSpecError> {
275 if m.tools.is_empty() && m.vsix.is_empty() {
276 return Err(LayerSpecError::Empty);
277 }
278 encodable("realm.name", &m.realm.name)?;
279 encodable("realm.channel", &m.realm.channel)?;
280 encodable("varve.version", &m.varve.version)?;
281
282 let mut tarballs: Vec<String> = Vec::new();
283 let mut wsc_version: Option<String> = None;
284 let mut raw_owner: Option<String> = None;
285 let mut seen_tools: BTreeSet<&str> = BTreeSet::new();
286
287 for t in &m.tools {
288 encodable("tool.name", &t.name)?;
289 encodable("tool.version", &t.version)?;
290 if !seen_tools.insert(t.name.as_str()) {
291 return Err(LayerSpecError::Duplicate {
292 kind: "tool",
293 name: t.name.clone(),
294 });
295 }
296 let layout = match t.layout.as_deref() {
297 None => Layout::Tarball,
298 Some(s) => Layout::parse(s).ok_or_else(|| LayerSpecError::UnknownLayout {
299 tool: t.name.clone(),
300 layout: s.to_string(),
301 })?,
302 };
303 let (owner, repo_name) = match &t.repo {
304 Some(r) => {
305 encodable("tool.repo", r)?;
306 split_repo(r, &t.name)
307 }
308 None => ("pulseengine".to_string(), t.name.clone()),
309 };
310
311 if layout == Layout::RawPerPlatform {
312 if let Some(first) = &raw_owner {
313 return Err(LayerSpecError::ManyRawPerPlatform {
314 first: first.clone(),
315 second: t.name.clone(),
316 });
317 }
318 raw_owner = Some(t.name.clone());
319 wsc_version = Some(t.version.clone());
320 continue;
321 }
322
323 if repo_name != t.name {
331 return Err(LayerSpecError::RepoNameMismatch {
332 name: t.name.clone(),
333 repo: t.repo.clone().unwrap_or_else(|| repo_name.clone()),
334 basename: repo_name.clone(),
335 });
336 }
337
338 let head = if owner == "pulseengine" {
345 t.name.clone()
346 } else {
347 format!("{owner}/{repo_name}")
348 };
349 let mut entry = format!("{head}:{}", t.version);
350 match (&t.binary, &t.asset) {
352 (None, None) => {}
353 (Some(b), None) => {
354 encodable("tool.binary", b)?;
355 entry.push(':');
356 entry.push_str(b);
357 }
358 (b, Some(a)) => {
359 encodable("tool.asset", a)?;
360 let bin = b.clone().unwrap_or_else(|| t.name.clone());
361 encodable("tool.binary", &bin)?;
362 entry.push(':');
363 entry.push_str(&bin);
364 entry.push(':');
365 entry.push_str(a);
366 }
367 }
368 tarballs.push(entry);
369 }
370
371 let mut vsix_entries: Vec<String> = Vec::new();
372 let mut seen_vsix: BTreeSet<&str> = BTreeSet::new();
373 for v in &m.vsix {
374 encodable("vsix.name", &v.name)?;
375 encodable("vsix.version", &v.version)?;
376 encodable("vsix.asset", &v.asset)?;
377 if !seen_vsix.insert(v.name.as_str()) {
378 return Err(LayerSpecError::Duplicate {
379 kind: "vsix",
380 name: v.name.clone(),
381 });
382 }
383 let repo_name = match &v.repo {
387 Some(r) => {
388 encodable("vsix.repo", r)?;
389 let (owner, name) = split_repo(r, &v.name);
390 if owner != "pulseengine" {
391 return Err(LayerSpecError::VsixForeignOwner {
392 name: v.name.clone(),
393 repo: r.clone(),
394 });
395 }
396 name
397 }
398 None => v.name.clone(),
399 };
400 vsix_entries.push(format!("{repo_name}:{}:{}:{}", v.version, v.name, v.asset));
401 }
402
403 Ok(AssemblerEnv {
404 layer_tools: tarballs.join(" "),
405 wsc_version,
406 vsix_packages: vsix_entries.join(" "),
407 realm: m.realm.name.clone(),
408 channel: m.realm.channel.clone(),
409 registry: m.realm.registry.clone(),
410 varve_version: m.varve.version.clone(),
411 })
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 const REAL: &str = r#"
422[varve]
423version = "v0.28.0"
424
425[realm]
426name = "pulseengine"
427channel = "rolling"
428registry = "oci://ghcr.io/pulseengine/varve/layers"
429
430[[tool]]
431name = "rivet"
432version = "v0.34.0"
433
434[[tool]]
435name = "kiln"
436version = "v0.4.4"
437binary = "kilnd"
438
439[[tool]]
440name = "wsc"
441repo = "pulseengine/sigil"
442version = "v0.11.0"
443layout = "raw-per-platform"
444
445[[vsix]]
446name = "rivet-sdlc"
447repo = "pulseengine/rivet"
448version = "v0.34.0"
449asset = "rivet-sdlc-%V.vsix"
450
451[[vsix]]
452name = "spar-aadl"
453repo = "pulseengine/spar"
454version = "v0.40.0"
455asset = "spar-aadl-%P-%V.vsix"
456"#;
457
458 fn env_of(text: &str) -> AssemblerEnv {
459 assembler_env(&parse_layer_manifest(text).expect("parses")).expect("converts")
460 }
461
462 #[test]
464 fn a_plain_tool_becomes_name_and_version() {
465 assert!(env_of(REAL).layer_tools.starts_with("rivet:v0.34.0 "));
466 }
467
468 #[test]
470 fn a_differing_binary_name_is_carried_as_the_third_field() {
471 assert!(env_of(REAL).layer_tools.contains("kiln:v0.4.4:kilnd"));
472 }
473
474 #[test]
478 fn the_raw_per_platform_tool_becomes_wsc_version_and_not_a_tarball() {
479 let e = env_of(REAL);
480 assert_eq!(e.wsc_version.as_deref(), Some("v0.11.0"));
481 assert!(!e.layer_tools.contains("wsc"), "{}", e.layer_tools);
482 assert!(!e.layer_tools.contains("sigil"), "{}", e.layer_tools);
483 }
484
485 #[test]
487 fn vsix_entries_drop_the_default_owner_the_assembler_re_adds() {
488 let e = env_of(REAL);
489 assert_eq!(
490 e.vsix_packages,
491 "rivet:v0.34.0:rivet-sdlc:rivet-sdlc-%V.vsix \
492 spar:v0.40.0:spar-aadl:spar-aadl-%P-%V.vsix"
493 );
494 }
495
496 #[test]
500 fn a_mistyped_key_is_refused_rather_than_ignored() {
501 let text = REAL.replace("name = \"rivet\"", "nmae = \"rivet\"");
502 let err = parse_layer_manifest(&text).unwrap_err();
503 assert!(
504 matches!(&err, LayerSpecError::Parse(m) if m.contains("nmae")),
505 "{err}"
506 );
507 }
508
509 #[test]
511 fn an_unknown_layout_is_refused_and_names_the_two_that_work() {
512 let text = REAL.replace("raw-per-platform", "zipfile");
513 let err = assembler_env(&parse_layer_manifest(&text).unwrap()).unwrap_err();
514 let msg = err.to_string();
515 assert!(
516 msg.contains("zipfile") && msg.contains("raw-per-platform"),
517 "{msg}"
518 );
519 }
520
521 #[test]
525 fn a_second_raw_per_platform_tool_is_refused_rather_than_dropped() {
526 let text = format!(
527 "{REAL}\n[[tool]]\nname = \"other\"\nversion = \"v1.0.0\"\nlayout = \"raw-per-platform\"\n"
528 );
529 let err = assembler_env(&parse_layer_manifest(&text).unwrap()).unwrap_err();
530 assert_eq!(
531 err,
532 LayerSpecError::ManyRawPerPlatform {
533 first: "wsc".into(),
534 second: "other".into()
535 }
536 );
537 }
538
539 #[test]
543 fn a_foreign_owner_on_an_extension_is_refused() {
544 let text = REAL.replace(
545 "repo = \"pulseengine/rivet\"",
546 "repo = \"acme/rivet\"",
547 );
548 let err = assembler_env(&parse_layer_manifest(&text).unwrap()).unwrap_err();
549 assert_eq!(
550 err,
551 LayerSpecError::VsixForeignOwner {
552 name: "rivet-sdlc".into(),
553 repo: "acme/rivet".into()
554 }
555 );
556 }
557
558 #[test]
562 fn a_value_carrying_a_separator_is_refused() {
563 for (bad, why) in [("v1.0 rc1", "whitespace"), ("v1.0:rc1", "':'")] {
564 let text = REAL.replace("v0.34.0\"\n\n[[tool]]", &format!("{bad}\"\n\n[[tool]]"));
565 let err = assembler_env(&parse_layer_manifest(&text).unwrap()).unwrap_err();
566 let msg = err.to_string();
567 assert!(msg.contains(why), "{bad}: {msg}");
568 }
569 }
570
571 #[test]
577 fn an_explicit_tarball_layout_means_the_same_as_omitting_it() {
578 let explicit = REAL.replace(
579 "name = \"rivet\"\nversion = \"v0.34.0\"",
580 "name = \"rivet\"\nversion = \"v0.34.0\"\nlayout = \"tarball\"",
581 );
582 assert_ne!(explicit, REAL, "the fixture substitution must apply");
583 assert_eq!(env_of(&explicit).layer_tools, env_of(REAL).layer_tools);
584 }
585
586 #[test]
591 fn a_foreign_owner_is_carried_as_a_qualified_repository() {
592 let text = format!(
593 "{REAL}\n[[tool]]\nname = \"wasm-tools\"\nrepo = \"bytecodealliance/wasm-tools\"\nversion = \"v1.257.1\"\n"
594 );
595 assert!(
596 env_of(&text)
597 .layer_tools
598 .contains("bytecodealliance/wasm-tools:v1.257.1"),
599 "{}",
600 env_of(&text).layer_tools
601 );
602 }
603
604 #[test]
611 fn a_tool_whose_repo_basename_disagrees_with_its_name_is_refused() {
612 let text = format!(
613 "{REAL}\n[[tool]]\nname = \"wsc2\"\nrepo = \"pulseengine/sigil\"\nversion = \"v0.11.0\"\n"
614 );
615 let err = assembler_env(&parse_layer_manifest(&text).unwrap()).unwrap_err();
616 assert_eq!(
617 err,
618 LayerSpecError::RepoNameMismatch {
619 name: "wsc2".into(),
620 repo: "pulseengine/sigil".into(),
621 basename: "sigil".into(),
622 },
623 "{err}"
624 );
625 }
626
627 #[test]
629 fn two_tools_of_one_name_are_refused() {
630 let text = format!("{REAL}\n[[tool]]\nname = \"rivet\"\nversion = \"v0.1.0\"\n");
631 let err = assembler_env(&parse_layer_manifest(&text).unwrap()).unwrap_err();
632 assert_eq!(
633 err,
634 LayerSpecError::Duplicate {
635 kind: "tool",
636 name: "rivet".into()
637 }
638 );
639 }
640
641 #[test]
643 fn a_manifest_with_no_payloads_is_refused() {
644 let text = "[varve]\nversion = \"v0.28.0\"\n\n[realm]\nname = \"p\"\nchannel = \"rolling\"\nregistry = \"oci://x\"\n";
645 assert_eq!(
646 assembler_env(&parse_layer_manifest(text).unwrap()).unwrap_err(),
647 LayerSpecError::Empty
648 );
649 }
650
651 #[test]
655 fn a_layer_with_no_extensions_still_sets_the_variable() {
656 let text = REAL
657 .split("[[vsix]]")
658 .next()
659 .expect("has a tools section")
660 .to_string();
661 let rendered = env_of(&text).render();
662 assert!(rendered.contains("\nVSIX_PACKAGES=\n"), "{rendered}");
663 }
664
665 #[test]
667 fn render_emits_one_key_per_line_in_a_stable_order() {
668 let rendered = env_of(REAL).render();
669 let keys: Vec<&str> = rendered
670 .lines()
671 .map(|l| l.split('=').next().unwrap_or(""))
672 .collect();
673 assert_eq!(
674 keys,
675 [
676 "TARBALL_TOOLS",
677 "WSC_VERSION",
678 "VSIX_PACKAGES",
679 "VARVE_REALM",
680 "VARVE_CHANNEL",
681 "VARVE_REGISTRY",
682 "VARVE_VERSION"
683 ]
684 );
685 }
686}