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 #[serde(rename = "unverified-reason", default)]
93 pub unverified_reason: Option<String>,
94 #[serde(rename = "asset-for", default)]
103 pub asset_for: std::collections::BTreeMap<String, String>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
107#[serde(deny_unknown_fields)]
108pub struct ManifestVsix {
109 pub name: String,
110 pub version: String,
111 #[serde(default)]
112 pub repo: Option<String>,
113 pub asset: String,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct LayerManifest {
125 pub varve: VarvePin,
126 pub realm: ManifestRealm,
127 #[serde(default, rename = "tool")]
128 pub tools: Vec<ManifestTool>,
129 #[serde(default, rename = "vsix")]
130 pub vsix: Vec<ManifestVsix>,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum LayerSpecError {
135 Parse(String),
137 Unencodable {
139 field: String,
140 value: String,
141 why: &'static str,
142 },
143 UnknownLayout { tool: String, layout: String },
145 ManyRawPerPlatform { first: String, second: String },
148 RawPerPlatformNotWsc { tool: String, repo: String },
152 VsixForeignOwner { name: String, repo: String },
154 RepoNameMismatch {
157 name: String,
158 repo: String,
159 basename: String,
160 },
161 Duplicate { kind: &'static str, name: String },
163 UnverifiedWithoutReason { tool: String },
165 ConflictingReason {
167 repo: String,
168 first: String,
169 second: String,
170 },
171 Empty,
173}
174
175impl fmt::Display for LayerSpecError {
176 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177 match self {
178 LayerSpecError::Parse(e) => write!(f, "layer.toml does not parse: {e}"),
179 LayerSpecError::Unencodable { field, value, why } => write!(
180 f,
181 "{field} = {value:?} cannot be passed to the assembler: {why}. \
182 The assembler reads space-separated entries of colon-separated \
183 fields, so such a value would be split or truncated silently \
184 and the layer would carry the wrong bytes under a good signature."
185 ),
186 LayerSpecError::UnknownLayout { tool, layout } => write!(
187 f,
188 "tool {tool:?} declares layout = {layout:?}, which varve does not \
189 implement. Use \"tarball\" (one .tar.gz per target triple) or \
190 \"raw-per-platform\" (bare per-platform binaries)."
191 ),
192 LayerSpecError::ManyRawPerPlatform { first, second } => write!(
193 f,
194 "tools {first:?} and {second:?} both declare \
195 layout = \"raw-per-platform\", and the assembler carries only \
196 one (as WSC_VERSION). Depositing would silently drop one of \
197 them. Teach the assembler a general raw-per-platform list \
198 before adding the second."
199 ),
200 LayerSpecError::RawPerPlatformNotWsc { tool, repo } => write!(
201 f,
202 "tool {tool:?} from {repo:?} declares \
203 layout = \"raw-per-platform\", but the assembler's only slot \
204 for that layout is hardcoded to fetch `wsc` from \
205 `pulseengine/sigil` — it would emit WSC_VERSION and download \
206 the wrong tool, from the wrong repository, at this tool's \
207 version, and deposit it under the wrong name. Teach the \
208 assembler a general raw-per-platform list before carrying \
209 this."
210 ),
211 LayerSpecError::VsixForeignOwner { name, repo } => write!(
212 f,
213 "vsix {name:?} names repo {repo:?}, but the assembler resolves \
214 extension repositories as pulseengine/<name> and would fetch \
215 the wrong release. Either publish it under pulseengine, or \
216 teach the assembler an owner field for extensions."
217 ),
218 LayerSpecError::RepoNameMismatch {
219 name,
220 repo,
221 basename,
222 } => write!(
223 f,
224 "tool {name:?} names repo {repo:?}, but the assembler takes a \
225 tarball tool's identity from the repository basename — it \
226 would download, name the payload, and default the asset \
227 template as {basename:?}, and {name:?} would mean nothing. A \
228 consumer asking for {name:?} would then find no such tool in \
229 a layer that deposited and verified. Rename the entry to \
230 {basename:?}, or set `binary` if only the executable differs."
231 ),
232 LayerSpecError::Duplicate { kind, name } => {
233 write!(f, "two {kind} entries are both named {name:?}")
234 }
235 LayerSpecError::UnverifiedWithoutReason { tool } => write!(
236 f,
237 "tool {tool:?} sets an empty `unverified-reason`. \"We could \
238 not verify this\" must never be the silent path: the reason \
239 is what travels with the bytes into the signed layer, where \
240 every consumer reads it. Say why this is acceptable and what \
241 removes the need, or do not carry the tool."
242 ),
243 LayerSpecError::ConflictingReason {
244 repo,
245 first,
246 second,
247 } => write!(
248 f,
249 "two tools from {repo:?} give different reasons for ingesting \
250 it unverified:\n {first:?}\n {second:?}\nThe opt-in is per \
251 RELEASE, not per tool, so one of these would be recorded and \
252 the other silently discarded. Give the repository one reason."
253 ),
254 LayerSpecError::Empty => write!(
255 f,
256 "layer.toml declares no [[tool]] and no [[vsix]]: there is \
257 nothing to deposit. A layer with no payloads is signed, \
258 published, and useless."
259 ),
260 }
261 }
262}
263
264impl std::error::Error for LayerSpecError {}
265
266#[derive(Debug, Clone, PartialEq, Eq)]
272pub struct AssemblerEnv {
273 pub layer_tools: String,
274 pub wsc_version: Option<String>,
275 pub vsix_packages: String,
276 pub realm: String,
277 pub channel: String,
278 pub registry: String,
279 pub varve_version: String,
280 pub unverified_ingest: Vec<(String, String)>,
282}
283
284impl AssemblerEnv {
285 pub fn render(&self) -> String {
287 let mut out = String::new();
288 out.push_str(&format!("TARBALL_TOOLS={}\n", self.layer_tools));
289 out.push_str(&format!(
290 "WSC_VERSION={}\n",
291 self.wsc_version.as_deref().unwrap_or("")
292 ));
293 out.push_str(&format!("VSIX_PACKAGES={}\n", self.vsix_packages));
294 out.push_str(&format!("VARVE_REALM={}\n", self.realm));
295 out.push_str(&format!("VARVE_CHANNEL={}\n", self.channel));
296 out.push_str(&format!("VARVE_REGISTRY={}\n", self.registry));
297 out.push_str(&format!("VARVE_VERSION={}\n", self.varve_version));
298 if !self.unverified_ingest.is_empty() {
308 let body: String = self
309 .unverified_ingest
310 .iter()
311 .map(|(repo, why)| format!("{repo}={why}\n"))
312 .collect();
313 let mut delim = String::from("VARVE_UNVERIFIED_EOF");
314 while body.contains(&delim) {
315 delim.push('_');
316 }
317 out.push_str(&format!("UNVERIFIED_INGEST<<{delim}\n{body}{delim}\n"));
318 }
319 out
320 }
321}
322
323pub fn parse_layer_manifest(text: &str) -> Result<LayerManifest, LayerSpecError> {
324 toml::from_str(text).map_err(|e| LayerSpecError::Parse(e.to_string()))
325}
326
327fn encodable(field: &str, value: &str) -> Result<(), LayerSpecError> {
333 let unencodable = |why| LayerSpecError::Unencodable {
334 field: field.to_string(),
335 value: value.to_string(),
336 why,
337 };
338 if value.is_empty() {
339 return Err(unencodable("it is empty"));
340 }
341 if value.contains(':') {
342 return Err(unencodable("it contains ':', which separates fields"));
343 }
344 if value.chars().any(char::is_whitespace) {
345 return Err(unencodable(
346 "it contains whitespace, which separates entries",
347 ));
348 }
349 Ok(())
350}
351
352fn split_repo(repo: &str, default_name: &str) -> (String, String) {
354 match repo.split_once('/') {
355 Some((owner, name)) => (owner.to_string(), name.to_string()),
356 None => ("pulseengine".to_string(), default_name.to_string()),
357 }
358}
359
360pub fn assembler_env(m: &LayerManifest) -> Result<AssemblerEnv, LayerSpecError> {
361 if m.tools.is_empty() && m.vsix.is_empty() {
362 return Err(LayerSpecError::Empty);
363 }
364 encodable("realm.name", &m.realm.name)?;
365 encodable("realm.channel", &m.realm.channel)?;
366 encodable("varve.version", &m.varve.version)?;
367
368 let mut unverified: Vec<(String, String)> = Vec::new();
369 let mut tarballs: Vec<String> = Vec::new();
370 let mut wsc_version: Option<String> = None;
371 let mut raw_owner: Option<String> = None;
372 let mut seen_tools: BTreeSet<&str> = BTreeSet::new();
373
374 for t in &m.tools {
375 encodable("tool.name", &t.name)?;
376 encodable("tool.version", &t.version)?;
377 if !seen_tools.insert(t.name.as_str()) {
378 return Err(LayerSpecError::Duplicate {
379 kind: "tool",
380 name: t.name.clone(),
381 });
382 }
383 let layout = match t.layout.as_deref() {
384 None => Layout::Tarball,
385 Some(s) => Layout::parse(s).ok_or_else(|| LayerSpecError::UnknownLayout {
386 tool: t.name.clone(),
387 layout: s.to_string(),
388 })?,
389 };
390 if let Some(why) = &t.unverified_reason {
394 let why = why.trim();
395 if why.is_empty() {
396 return Err(LayerSpecError::UnverifiedWithoutReason {
397 tool: t.name.clone(),
398 });
399 }
400 let full = match &t.repo {
401 Some(r) => r.clone(),
402 None => format!("pulseengine/{}", t.name),
403 };
404 if let Some((_, prev)) = unverified.iter().find(|(r, _)| *r == full) {
405 if prev != why {
406 return Err(LayerSpecError::ConflictingReason {
407 repo: full,
408 first: prev.clone(),
409 second: why.to_string(),
410 });
411 }
412 } else {
413 unverified.push((full, why.to_string()));
414 }
415 }
416 let (owner, repo_name) = match &t.repo {
417 Some(r) => {
418 encodable("tool.repo", r)?;
419 split_repo(r, &t.name)
420 }
421 None => ("pulseengine".to_string(), t.name.clone()),
422 };
423
424 if layout == Layout::RawPerPlatform {
425 if let Some(first) = &raw_owner {
426 return Err(LayerSpecError::ManyRawPerPlatform {
427 first: first.clone(),
428 second: t.name.clone(),
429 });
430 }
431 if t.name != "wsc" || owner != "pulseengine" || repo_name != "sigil" {
435 return Err(LayerSpecError::RawPerPlatformNotWsc {
436 tool: t.name.clone(),
437 repo: format!("{owner}/{repo_name}"),
438 });
439 }
440 raw_owner = Some(t.name.clone());
441 wsc_version = Some(t.version.clone());
442 continue;
443 }
444
445 if repo_name != t.name {
453 return Err(LayerSpecError::RepoNameMismatch {
454 name: t.name.clone(),
455 repo: t.repo.clone().unwrap_or_else(|| repo_name.clone()),
456 basename: repo_name.clone(),
457 });
458 }
459
460 let head = if owner == "pulseengine" {
467 t.name.clone()
468 } else {
469 format!("{owner}/{repo_name}")
470 };
471 let mut entry = format!("{head}:{}", t.version);
472 match (&t.binary, &t.asset) {
474 (None, None) => {}
475 (Some(b), None) => {
476 encodable("tool.binary", b)?;
477 entry.push(':');
478 entry.push_str(b);
479 }
480 (b, Some(a)) => {
481 encodable("tool.asset", a)?;
482 let bin = b.clone().unwrap_or_else(|| t.name.clone());
483 encodable("tool.binary", &bin)?;
484 entry.push(':');
485 entry.push_str(&bin);
486 entry.push(':');
487 entry.push_str(a);
488 }
489 }
490 tarballs.push(entry);
491 }
492
493 let mut vsix_entries: Vec<String> = Vec::new();
494 let mut seen_vsix: BTreeSet<&str> = BTreeSet::new();
495 for v in &m.vsix {
496 encodable("vsix.name", &v.name)?;
497 encodable("vsix.version", &v.version)?;
498 encodable("vsix.asset", &v.asset)?;
499 if !seen_vsix.insert(v.name.as_str()) {
500 return Err(LayerSpecError::Duplicate {
501 kind: "vsix",
502 name: v.name.clone(),
503 });
504 }
505 let repo_name = match &v.repo {
509 Some(r) => {
510 encodable("vsix.repo", r)?;
511 let (owner, name) = split_repo(r, &v.name);
512 if owner != "pulseengine" {
513 return Err(LayerSpecError::VsixForeignOwner {
514 name: v.name.clone(),
515 repo: r.clone(),
516 });
517 }
518 name
519 }
520 None => v.name.clone(),
521 };
522 vsix_entries.push(format!("{repo_name}:{}:{}:{}", v.version, v.name, v.asset));
523 }
524
525 Ok(AssemblerEnv {
526 layer_tools: tarballs.join(" "),
527 wsc_version,
528 vsix_packages: vsix_entries.join(" "),
529 realm: m.realm.name.clone(),
530 channel: m.realm.channel.clone(),
531 registry: m.realm.registry.clone(),
532 varve_version: m.varve.version.clone(),
533 unverified_ingest: unverified,
534 })
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540
541 const REAL: &str = r#"
545[varve]
546version = "v0.28.0"
547
548[realm]
549name = "pulseengine"
550channel = "rolling"
551registry = "oci://ghcr.io/pulseengine/varve/layers"
552
553[[tool]]
554name = "rivet"
555version = "v0.34.0"
556
557[[tool]]
558name = "kiln"
559version = "v0.4.4"
560binary = "kilnd"
561
562[[tool]]
563name = "wsc"
564repo = "pulseengine/sigil"
565version = "v0.11.0"
566layout = "raw-per-platform"
567
568[[vsix]]
569name = "rivet-sdlc"
570repo = "pulseengine/rivet"
571version = "v0.34.0"
572asset = "rivet-sdlc-%V.vsix"
573
574[[vsix]]
575name = "spar-aadl"
576repo = "pulseengine/spar"
577version = "v0.40.0"
578asset = "spar-aadl-%P-%V.vsix"
579"#;
580
581 fn env_of(text: &str) -> AssemblerEnv {
582 assembler_env(&parse_layer_manifest(text).expect("parses")).expect("converts")
583 }
584
585 #[test]
587 fn a_plain_tool_becomes_name_and_version() {
588 assert!(env_of(REAL).layer_tools.starts_with("rivet:v0.34.0 "));
589 }
590
591 #[test]
593 fn a_differing_binary_name_is_carried_as_the_third_field() {
594 assert!(env_of(REAL).layer_tools.contains("kiln:v0.4.4:kilnd"));
595 }
596
597 #[test]
601 fn the_raw_per_platform_tool_becomes_wsc_version_and_not_a_tarball() {
602 let e = env_of(REAL);
603 assert_eq!(e.wsc_version.as_deref(), Some("v0.11.0"));
604 assert!(!e.layer_tools.contains("wsc"), "{}", e.layer_tools);
605 assert!(!e.layer_tools.contains("sigil"), "{}", e.layer_tools);
606 }
607
608 #[test]
610 fn vsix_entries_drop_the_default_owner_the_assembler_re_adds() {
611 let e = env_of(REAL);
612 assert_eq!(
613 e.vsix_packages,
614 "rivet:v0.34.0:rivet-sdlc:rivet-sdlc-%V.vsix \
615 spar:v0.40.0:spar-aadl:spar-aadl-%P-%V.vsix"
616 );
617 }
618
619 #[test]
623 fn a_mistyped_key_is_refused_rather_than_ignored() {
624 let text = REAL.replace("name = \"rivet\"", "nmae = \"rivet\"");
625 let err = parse_layer_manifest(&text).unwrap_err();
626 assert!(
627 matches!(&err, LayerSpecError::Parse(m) if m.contains("nmae")),
628 "{err}"
629 );
630 }
631
632 #[test]
634 fn an_unknown_layout_is_refused_and_names_the_two_that_work() {
635 let text = REAL.replace("raw-per-platform", "zipfile");
636 let err = assembler_env(&parse_layer_manifest(&text).unwrap()).unwrap_err();
637 let msg = err.to_string();
638 assert!(
639 msg.contains("zipfile") && msg.contains("raw-per-platform"),
640 "{msg}"
641 );
642 }
643
644 #[test]
648 fn a_second_raw_per_platform_tool_is_refused_rather_than_dropped() {
649 let text = format!(
650 "{REAL}\n[[tool]]\nname = \"other\"\nversion = \"v1.0.0\"\nlayout = \"raw-per-platform\"\n"
651 );
652 let err = assembler_env(&parse_layer_manifest(&text).unwrap()).unwrap_err();
653 assert_eq!(
654 err,
655 LayerSpecError::ManyRawPerPlatform {
656 first: "wsc".into(),
657 second: "other".into()
658 }
659 );
660 }
661
662 #[test]
670 fn a_raw_per_platform_tool_that_is_not_wsc_is_refused() {
671 let text = format!(
672 "{REAL}\n[[tool]]\nname = \"wac\"\nrepo = \"bytecodealliance/wac\"\n\
673 version = \"v0.10.1\"\nlayout = \"raw-per-platform\"\n"
674 );
675 let only = text.replace(
678 "[[tool]]\nname = \"wsc\"\nrepo = \"pulseengine/sigil\"\nversion = \"v0.11.0\"\nlayout = \"raw-per-platform\"\n",
679 "",
680 );
681 assert!(!only.contains("wsc"), "the wsc block must be gone: {only}");
682 let err = assembler_env(&parse_layer_manifest(&only).unwrap()).unwrap_err();
683 assert_eq!(
684 err,
685 LayerSpecError::RawPerPlatformNotWsc {
686 tool: "wac".into(),
687 repo: "bytecodealliance/wac".into()
688 },
689 "{err}"
690 );
691 assert!(err.to_string().contains("wrong repository"), "{err}");
692 }
693
694 #[test]
701 fn the_wsc_slot_rejects_a_tool_that_differs_in_any_single_field() {
702 let base = REAL.replace(
703 "[[tool]]\nname = \"wsc\"\nrepo = \"pulseengine/sigil\"\nversion = \"v0.11.0\"\nlayout = \"raw-per-platform\"\n",
704 "",
705 );
706 for (name, repo, what) in [
707 ("wsc", "acme/sigil", "a different OWNER"),
708 ("wsc", "pulseengine/other", "a different REPOSITORY"),
709 ("other", "pulseengine/sigil", "a different TOOL NAME"),
710 ] {
711 let text = format!(
712 "{base}\n[[tool]]\nname = \"{name}\"\nrepo = \"{repo}\"\n\
713 version = \"v1.0.0\"\nlayout = \"raw-per-platform\"\n"
714 );
715 let err = assembler_env(&parse_layer_manifest(&text).unwrap()).expect_err(what);
716 assert!(
717 matches!(err, LayerSpecError::RawPerPlatformNotWsc { .. }),
718 "{what} ({name} from {repo}) was not refused as a wsc-slot \
719 mismatch: {err:?}"
720 );
721 }
722 }
723
724 #[test]
728 fn a_foreign_owner_on_an_extension_is_refused() {
729 let text = REAL.replace(
730 "repo = \"pulseengine/rivet\"",
731 "repo = \"acme/rivet\"",
732 );
733 let err = assembler_env(&parse_layer_manifest(&text).unwrap()).unwrap_err();
734 assert_eq!(
735 err,
736 LayerSpecError::VsixForeignOwner {
737 name: "rivet-sdlc".into(),
738 repo: "acme/rivet".into()
739 }
740 );
741 }
742
743 #[test]
747 fn a_value_carrying_a_separator_is_refused() {
748 for (bad, why) in [("v1.0 rc1", "whitespace"), ("v1.0:rc1", "':'")] {
749 let text = REAL.replace("v0.34.0\"\n\n[[tool]]", &format!("{bad}\"\n\n[[tool]]"));
750 let err = assembler_env(&parse_layer_manifest(&text).unwrap()).unwrap_err();
751 let msg = err.to_string();
752 assert!(msg.contains(why), "{bad}: {msg}");
753 }
754 }
755
756 #[test]
762 fn an_explicit_tarball_layout_means_the_same_as_omitting_it() {
763 let explicit = REAL.replace(
764 "name = \"rivet\"\nversion = \"v0.34.0\"",
765 "name = \"rivet\"\nversion = \"v0.34.0\"\nlayout = \"tarball\"",
766 );
767 assert_ne!(explicit, REAL, "the fixture substitution must apply");
768 assert_eq!(env_of(&explicit).layer_tools, env_of(REAL).layer_tools);
769 }
770
771 #[test]
776 fn a_foreign_owner_is_carried_as_a_qualified_repository() {
777 let text = format!(
778 "{REAL}\n[[tool]]\nname = \"wasm-tools\"\nrepo = \"bytecodealliance/wasm-tools\"\nversion = \"v1.257.1\"\n"
779 );
780 assert!(
781 env_of(&text)
782 .layer_tools
783 .contains("bytecodealliance/wasm-tools:v1.257.1"),
784 "{}",
785 env_of(&text).layer_tools
786 );
787 }
788
789 #[test]
796 fn a_tool_whose_repo_basename_disagrees_with_its_name_is_refused() {
797 let text = format!(
798 "{REAL}\n[[tool]]\nname = \"wsc2\"\nrepo = \"pulseengine/sigil\"\nversion = \"v0.11.0\"\n"
799 );
800 let err = assembler_env(&parse_layer_manifest(&text).unwrap()).unwrap_err();
801 assert_eq!(
802 err,
803 LayerSpecError::RepoNameMismatch {
804 name: "wsc2".into(),
805 repo: "pulseengine/sigil".into(),
806 basename: "sigil".into(),
807 },
808 "{err}"
809 );
810 }
811
812 #[test]
814 fn two_tools_of_one_name_are_refused() {
815 let text = format!("{REAL}\n[[tool]]\nname = \"rivet\"\nversion = \"v0.1.0\"\n");
816 let err = assembler_env(&parse_layer_manifest(&text).unwrap()).unwrap_err();
817 assert_eq!(
818 err,
819 LayerSpecError::Duplicate {
820 kind: "tool",
821 name: "rivet".into()
822 }
823 );
824 }
825
826 #[test]
833 fn an_unverified_reason_reaches_the_assembler_intact() {
834 let text = format!(
835 "{REAL}\n[[tool]]\nname = \"wac\"\nrepo = \"bytecodealliance/wac\"\n\
836 version = \"v0.10.1\"\nunverified-reason = \"publishes no sums, no cosign \
837 bundle and no attestation; tracked upstream, re-check each cut\"\n"
838 );
839 let env = env_of(&text);
840 assert_eq!(env.unverified_ingest.len(), 1);
841 assert_eq!(env.unverified_ingest[0].0, "bytecodealliance/wac");
842 assert!(env.unverified_ingest[0].1.contains("re-check each cut"));
843
844 let r = env.render();
847 assert!(r.contains("UNVERIFIED_INGEST<<"), "{r}");
848 assert!(r.contains("bytecodealliance/wac=publishes no sums"), "{r}");
849 }
850
851 #[test]
856 fn a_reason_containing_the_delimiter_cannot_close_the_block_early() {
857 let text = format!(
858 "{REAL}\n[[tool]]\nname = \"wac\"\nrepo = \"bytecodealliance/wac\"\n\
859 version = \"v0.10.1\"\nunverified-reason = \"VARVE_UNVERIFIED_EOF\\nPATH=/evil\"\n"
860 );
861 let r = env_of(&text).render();
862 let opened = r
863 .lines()
864 .find(|l| l.starts_with("UNVERIFIED_INGEST<<"))
865 .expect("heredoc opened");
866 let delim = opened.trim_start_matches("UNVERIFIED_INGEST<<");
867 let body = r.split(&format!("<<{delim}\n")).nth(1).expect("body");
869 let body = body.split(&format!("\n{delim}")).next().expect("closes");
870 assert!(
871 !body.contains(delim),
872 "delimiter occurs inside its own body"
873 );
874 assert!(
875 body.contains("PATH=/evil"),
876 "the reason must survive verbatim"
877 );
878 }
879
880 #[test]
883 fn an_empty_unverified_reason_is_refused() {
884 for bad in ["\"\"", "\" \""] {
885 let text = format!(
886 "{REAL}\n[[tool]]\nname = \"wac\"\nversion = \"v1\"\nunverified-reason = {bad}\n"
887 );
888 let err = assembler_env(&parse_layer_manifest(&text).unwrap()).unwrap_err();
889 assert_eq!(
890 err,
891 LayerSpecError::UnverifiedWithoutReason { tool: "wac".into() },
892 "{bad}"
893 );
894 }
895 }
896
897 #[test]
901 fn two_tools_from_one_repo_must_agree_on_the_reason() {
902 let text = REAL.replace(
907 "layout = \"raw-per-platform\"",
908 "layout = \"raw-per-platform\"\nunverified-reason = \"first\"",
909 ) + "\n[[tool]]\nname = \"sigil\"\nrepo = \"pulseengine/sigil\"\nversion = \"v0.11.0\"\n\
910 unverified-reason = \"second\"\n";
911 let err = assembler_env(&parse_layer_manifest(&text).unwrap()).unwrap_err();
912 assert!(
913 matches!(err, LayerSpecError::ConflictingReason { .. }),
914 "{err:?}"
915 );
916 let ok = text.replace("\"second\"", "\"first\"");
918 assert_eq!(env_of(&ok).unverified_ingest.len(), 1);
919 }
920
921 #[test]
925 fn a_manifest_with_nothing_unverified_emits_no_opt_in() {
926 let r = env_of(REAL).render();
927 assert!(!r.contains("UNVERIFIED_INGEST"), "{r}");
928 }
929
930 #[test]
932 fn a_manifest_with_no_payloads_is_refused() {
933 let text = "[varve]\nversion = \"v0.28.0\"\n\n[realm]\nname = \"p\"\nchannel = \"rolling\"\nregistry = \"oci://x\"\n";
934 assert_eq!(
935 assembler_env(&parse_layer_manifest(text).unwrap()).unwrap_err(),
936 LayerSpecError::Empty
937 );
938 }
939
940 #[test]
944 fn a_layer_with_no_extensions_still_sets_the_variable() {
945 let text = REAL
946 .split("[[vsix]]")
947 .next()
948 .expect("has a tools section")
949 .to_string();
950 let rendered = env_of(&text).render();
951 assert!(rendered.contains("\nVSIX_PACKAGES=\n"), "{rendered}");
952 }
953
954 #[test]
956 fn render_emits_one_key_per_line_in_a_stable_order() {
957 let rendered = env_of(REAL).render();
958 let keys: Vec<&str> = rendered
959 .lines()
960 .map(|l| l.split('=').next().unwrap_or(""))
961 .collect();
962 assert_eq!(
963 keys,
964 [
965 "TARBALL_TOOLS",
966 "WSC_VERSION",
967 "VSIX_PACKAGES",
968 "VARVE_REALM",
969 "VARVE_CHANNEL",
970 "VARVE_REGISTRY",
971 "VARVE_VERSION"
972 ]
973 );
974 }
975}