Skip to main content

varve_core/
layerspec.rs

1//! `layer.toml` → assembler inputs (REQ-LAYERREPO-001).
2//!
3//! A realm's contents belong in the realm's own repository, not in varve's
4//! workflow file: bumping `rivet` to v0.34.0 should be a one-line reviewed diff
5//! in `pulseengine-layers`, not a commit to the tool that signs it. This module
6//! is the adapter that makes that possible — it reads the manifest and produces
7//! the environment `tools/build-deposit-spec.sh` already consumes.
8//!
9//! It lives HERE, next to the assembler it feeds, rather than in the layers
10//! repository, because a realm running a *copy* of the assembler gets none of
11//! the system testing varve does on it (REQ-PEL-ASSEMBLER-001). One writer, one
12//! set of tests, every realm.
13//!
14//! ## Why this refuses so much
15//!
16//! The assembler's inputs are space-separated lists of colon-separated fields.
17//! That encoding cannot represent a value containing a space or a colon, and
18//! the shell will not complain — it will silently split one tool into two, or
19//! truncate a version. A layer assembled from a mangled list is still signed,
20//! still verifies, and carries the wrong bytes. So every field that lands in
21//! the encoding is checked against the encoding's own alphabet BEFORE it gets
22//! there, and anything the assembler cannot faithfully carry is an error rather
23//! than a best-effort translation. This is the same reasoning that made
24//! `UNVERIFIED_INGEST` line-separated instead of punctuation-separated.
25
26use std::collections::BTreeSet;
27use std::fmt;
28
29/// The pinned varve release that builds this layer.
30#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
31#[serde(deny_unknown_fields)]
32pub struct VarvePin {
33    pub version: String,
34}
35
36/// Which realm this layer belongs to, and where it is published.
37#[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/// How a tool's release presents its binaries.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Layout {
48    /// One `.tar.gz` per target triple — the PulseEngine norm.
49    Tarball,
50    /// Bare per-platform binaries with no archive (sigil ships `wsc` this way).
51    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    /// `owner/repo`. Absent = `pulseengine/<name>`.
70    #[serde(default)]
71    pub repo: Option<String>,
72    /// The executable's name, when it differs from the tool's (kiln ships
73    /// `kilnd`). Absent = `<name>`.
74    #[serde(default)]
75    pub binary: Option<String>,
76    /// Release asset name template; `%V` bare version, `%T` Rust target triple,
77    /// `%U` short upstream platform tag. Absent = the assembler's default.
78    #[serde(default)]
79    pub asset: Option<String>,
80    /// Absent = `tarball`.
81    #[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    /// Asset template; `%V` bare version, `%P` VS Code platform tag. A template
93    /// with no `%P` is one portable package.
94    pub asset: String,
95}
96
97/// The whole manifest. `deny_unknown_fields` throughout is load-bearing: a
98/// mistyped `verison = "v0.34.0"` would otherwise leave the real `version`
99/// missing or stale, and the layer would ship the wrong release under a good
100/// signature.
101#[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    /// The TOML did not parse, or carried a field the schema does not define.
115    Parse(String),
116    /// A value cannot survive the assembler's encoding.
117    Unencodable {
118        field: String,
119        value: String,
120        why: &'static str,
121    },
122    /// `layout = "..."` is not one the assembler implements.
123    UnknownLayout { tool: String, layout: String },
124    /// The assembler carries exactly one raw-per-platform tool, as
125    /// `WSC_VERSION`. A second one has nowhere to go.
126    ManyRawPerPlatform { first: String, second: String },
127    /// The assembler hardcodes `pulseengine/` for extension repositories.
128    VsixForeignOwner { name: String, repo: String },
129    /// The assembler derives a tarball tool's identity from its REPOSITORY
130    /// basename, so a manifest `name` that disagrees with it is discarded.
131    RepoNameMismatch {
132        name: String,
133        repo: String,
134        basename: String,
135    },
136    /// Two entries would land under one name.
137    Duplicate { kind: &'static str, name: String },
138    /// The manifest describes nothing to deposit.
139    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/// What the assembler needs in its environment, derived from the manifest.
204///
205/// Rendered as `KEY=value` lines so a workflow can append them to `$GITHUB_ENV`
206/// — no `eval`, no quoting round-trip, and nothing that would let a manifest
207/// value execute.
208#[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    /// `KEY=value` lines, newline-terminated, in a stable order.
221    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
241/// Reject anything the assembler's encoding cannot carry intact.
242///
243/// `%` is allowed: it is the asset templates' own metacharacter. `:` and
244/// whitespace are the encoding's separators, and an empty value would collapse
245/// a field position.
246fn 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
266/// `owner/repo` → (owner, repo), defaulting the owner to `pulseengine`.
267fn 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        // The assembler does `tool="${f_tool##*/}"` and then uses that name for
324        // the payload, the extract directory and the default asset template. A
325        // manifest whose `name` disagrees with the repository basename is
326        // therefore not translated — it is DISCARDED, and the layer deposits a
327        // payload under a name nobody asked for. `raw-per-platform` is exempt
328        // because the assembler hardcodes that one pairing (`wsc` from
329        // `pulseengine/sigil`) instead of deriving it.
330        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        // `[owner/]tool:version[:binary[:asset-template]]`. The owner prefix is
339        // omitted when it is the default, so a manifest that names no repos
340        // produces exactly the list the pre-migration workflow carried.
341        // `repo_name == t.name` holds by the check above, so testing it here
342        // too would be a condition no input could vary — cargo-mutants proved
343        // it dead by flipping it and killing nothing.
344        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        // A template cannot be given without a binary: they are positional.
351        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        // The assembler builds `pulseengine/<repo_name>` itself, so a foreign
384        // owner cannot be expressed — and quietly dropping the owner would
385        // fetch a DIFFERENT repository's release of the same name.
386        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    /// The manifest `pulseengine-layers` actually carries, trimmed to the
419    /// shapes that differ from one another. Kept as one literal so the tests
420    /// exercise a document that could really be committed, not a fragment.
421    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    // rivet: verifies REQ-LAYERADAPT-001
463    #[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    // rivet: verifies REQ-LAYERADAPT-001
469    #[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    /// The one raw-per-platform tool leaves TARBALL_TOOLS entirely — putting it
475    /// there would have the assembler look for a tarball that does not exist.
476    // rivet: verifies REQ-LAYERADAPT-001
477    #[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    // rivet: verifies REQ-LAYERADAPT-001
486    #[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    /// The manifest is the only place a version is written, so a typo there
497    /// must not be able to leave the real key at its previous value.
498    // rivet: verifies REQ-LAYERADAPT-001
499    #[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    // rivet: verifies REQ-LAYERADAPT-001
510    #[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    /// The assembler has exactly one slot. A second raw-per-platform tool would
522    /// otherwise be dropped from a layer that still signs and publishes.
523    // rivet: verifies REQ-LAYERADAPT-001
524    #[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    /// Dropping the owner would fetch pulseengine's release of the same name —
540    /// a different repository's bytes, deposited under a good signature.
541    // rivet: verifies REQ-LAYERADAPT-001
542    #[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    /// The encoding's separators cannot appear in the data. Both of these would
559    /// be split by the shell rather than reported.
560    // rivet: verifies REQ-LAYERADAPT-001
561    #[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    /// `tarball` is the default, but the docs say you may write it, so writing
572    /// it must not be refused. cargo-mutants found this by deleting the match
573    /// arm: every test used the default or `raw-per-platform`, so nothing
574    /// noticed that spelling it out stopped working.
575    // rivet: verifies REQ-LAYERADAPT-001
576    #[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    /// A foreign OWNER is fine — `bytecodealliance/wasm-tools` is the whole
587    /// point of the second realm — as long as the basename still identifies
588    /// the tool.
589    // rivet: verifies REQ-LAYERADAPT-001
590    #[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    /// The assembler names the payload from the repository basename, so a
605    /// disagreeing `name` is discarded rather than translated — the layer would
606    /// deposit and verify while carrying a tool under a name nobody asked for.
607    /// cargo-mutants found this too: no fixture had a tarball tool whose repo
608    /// basename differed, because the only differing repo was exempt.
609    // rivet: verifies REQ-LAYERADAPT-001
610    #[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    // rivet: verifies REQ-LAYERADAPT-001
628    #[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    // rivet: verifies REQ-LAYERADAPT-001
642    #[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    /// VSIX_PACKAGES must be SET-but-empty rather than absent: the assembler
652    /// distinguishes "this layer carries no extensions" from "someone forgot".
653    // rivet: verifies REQ-LAYERADAPT-001
654    #[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    // rivet: verifies REQ-LAYERADAPT-001
666    #[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}