diff --git a/README.adoc b/README.adoc index aa202fb..1976a21 100644 --- a/README.adoc +++ b/README.adoc @@ -210,6 +210,40 @@ stance`"* — this tool is Rust-primary now, with SPARK/Ada hooks planned for the correctness-critical `+integrity.rs+` path (called via Zig FFI per the hyperpolymath ABI/FFI standard). +=== Where a launcher keeps its state + +A generated launcher writes two files: a pid file and a log. Unless the +config says otherwise (`+[runtime]+` `+pid-file+` / `+log-file+`) they land +under the invoking user's own XDG directories, never in shared, +world-writable space: + +[cols="1,2,1",options="header"] +|=== +|File |Default |Overridden by + +|pid +|`+$XDG_RUNTIME_DIR+`, else `+$XDG_STATE_HOME+`, else +`+~/.local/state+` — as `+-server.pid+` +|`+[runtime]+` `+pid-file+` + +|log +|`+$XDG_STATE_HOME+`, else `+~/.local/state+` — as +`+-server.log+` +|`+[runtime]+` `+log-file+` +|=== + +The log goes to the *state* directory rather than the runtime directory +because it has to survive a logout, which `+$XDG_RUNTIME_DIR+` does not +promise. Both directories are created `+0700+` by the launcher before the +first write. + +Before 2026-09-25 both defaults were `+/tmp/-server.{pid,log}+`: +world-writable, and predictable from nothing but the app name, so any local +user could create or symlink the path before the launcher's first run and +influence what it later killed or removed (issue #48). Launchers minted +before that date keep their old paths until they are re-minted — set the +two keys explicitly, or re-mint, to move them. + === Why a declarative format for inputs * A launcher carries a metadata block in its own header, so a generated diff --git a/crates/launcher-common/src/config.rs b/crates/launcher-common/src/config.rs index f087dd3..b4abe95 100644 --- a/crates/launcher-common/src/config.rs +++ b/crates/launcher-common/src/config.rs @@ -88,8 +88,30 @@ pub struct Runtime { #[serde(default)] pub command: Vec, + /// Where the generated launcher writes its pid file. + /// + /// Default (when unset): `+$XDG_RUNTIME_DIR+`, falling back to + /// `+$XDG_STATE_HOME+` and then to `+~/.local/state+`, as + /// `+-server.pid+`. Before 2026-09-25 the default was + /// `+/tmp/-server.pid+` — world-writable and predicted entirely by + /// the app name, so any local user could create or symlink the path + /// before the launcher's first run and steer what it later killed or + /// removed (#48). The default is emitted into the script as a SHELL + /// expression, not resolved here, because the launcher runs on the + /// user's machine rather than the one it was minted on; the script + /// creates the directory `+0700+` before it writes. + /// + /// Set it to override, e.g. `+pid-file = "/var/run/myapp.pid"+`. A + /// leading `+~+` is expanded (see `+integration::expand_home+`). #[serde(default)] pub pid_file: Option, + /// Where the generated launcher writes its log. + /// + /// Default (when unset): `+$XDG_STATE_HOME+`, falling back to + /// `+~/.local/state+`, as `+-server.log+`. The state directory + /// rather than the runtime directory because a log has to survive a + /// logout, which `+$XDG_RUNTIME_DIR+` does not promise. Same history and + /// the same override mechanism as [`Runtime::pid_file`]. #[serde(default)] pub log_file: Option, diff --git a/crates/launcher-common/src/metadata_block.rs b/crates/launcher-common/src/metadata_block.rs index 774c2b4..3010fef 100644 --- a/crates/launcher-common/src/metadata_block.rs +++ b/crates/launcher-common/src/metadata_block.rs @@ -9,10 +9,10 @@ //! ```text //! # @a2ml-metadata begin //! # ( -//! # id = "burble-launcher" +//! # id = "stapeln-launcher" //! # type = "launcher" //! # version = "0.1.0" -//! # app-name = "burble" +//! # app-name = "stapeln" //! # runtime-kind = "server-url" //! # standards-compliance = [ //! # "launcher-standard.adoc" @@ -42,11 +42,11 @@ //! # ;; SPDX-License-Identifier: MPL-2.0 //! # (praxis-deed //! # :schema-version "1.0.0" -//! # :canonical-name "burble-launcher" +//! # :canonical-name "stapeln-launcher" //! # :beholding-chora #u5"estate/chora" //! # (artefact :type "launcher" :version "0.1.0" //! # :generator "launch-scaffolder") -//! # (app :name "burble" :display "Burble" +//! # (app :name "stapeln" :display "Stapeln" //! # :runtime-kind "server-url") //! # (compliance :standard-version "0.4.0" //! # :standards ("launcher-standard_praxis.deed"))) @@ -69,19 +69,69 @@ use crate::deed; use anyhow::{Context, bail}; use std::path::Path; -/// Required scalar keys that every well-formed metadata block must -/// carry, per `launcher-standard.adoc`. +/// Keys every well-formed metadata block must carry. +/// +/// This is **not** an independent opinion: it is a copy of the launcher +/// standard's own `(metadata-block :required-fields …)`, and +/// [`tests::required_keys_are_exactly_the_standard_required_fields`] asserts +/// the two are equal element for element, parsed from the vendored deed at +/// run time. Change one and the other must move with it (#41 AC1). +/// +/// The previous list disagreed with the standard in BOTH directions: it +/// demanded three keys the standard never asks for and silently accepted a +/// block missing four the standard requires. Both halves are now settled: +/// +/// * `runtime-kind`, `standard-spec-version` and `generator` are +/// **demoted to advisory** — see [`ADVISORY_SCALAR_KEYS`]. +/// * `app-url`, `standards-compliance`, `modes`, `platforms` and the two +/// lifecycle-phase lists are now checked, which is what closes the second +/// half of the disagreement. +/// * `standards-compliance` is a LIST, so the check that enforces this set +/// looks at list keys too, not only scalars. pub const REQUIRED_SCALAR_KEYS: &[&str] = &[ "id", "type", "version", "app-name", "app-display", - "runtime-kind", - "standard-spec-version", - "generator", + "app-url", + "standards-compliance", + "modes", + "platforms", + "lifecycle-phases-covered", + "lifecycle-phases-deferred", ]; +/// Keys this tool parses, emits and reports, but which the standard does NOT +/// require. +/// +/// Demoted out of the required set by #41 rather than added to the standard, +/// and the reason is the defect the issue was filed about: all three are +/// facts about the generator and the run, not about the launcher's contract +/// with the estate. Requiring them is precisely what made a launcher that +/// satisfies every published requirement read as non-conformant — +/// `hyperpolymath/trigger`'s launcher carries all eleven required fields and +/// none of these three, so this tool called it invalid while the standard +/// called it conformant. +/// +/// A requirement belongs in the standard, not in a parser; until the standard +/// claims them, `mint` keeps emitting them (they are useful provenance, and +/// every launcher minted so far carries them) and the guard keeps quiet about +/// their absence. +pub const ADVISORY_SCALAR_KEYS: &[&str] = &["runtime-kind", "standard-spec-version", "generator"]; + +/// The comment character every line of an embedded block carries. +/// +/// Named, and asserted equal to the standard's `(metadata-block (encoding +/// :comment-prefix …))`, because it is the one piece of the encoding a +/// reader cannot infer: the marker strings are visible in the file, but the +/// rule that every line is commented with a `#` before the grammar sees it +/// lived only in this module until #41. +pub const COMMENT_PREFIX: &str = "#"; + +/// The document head an embedded block must carry. +pub const DEED_HEAD: &str = "praxis-deed"; + /// Markers for the legacy block emitted by every launcher minted to date. pub const LEGACY_BEGIN: &str = "# @a2ml-metadata begin"; /// Closing marker for [`LEGACY_BEGIN`]. @@ -128,11 +178,28 @@ impl MetadataBlock { /// Validate the block carries every required key. Returns the list /// of missing keys (empty on success). + /// + /// Checks list keys as well as scalars. That matters for + /// `standards-compliance`: it is one of the standard's required fields and + /// it is a list, so a scalar-only check could never have found it missing + /// — the requirement was unfalsifiable as written (#41). pub fn missing_required(&self) -> Vec<&'static str> { REQUIRED_SCALAR_KEYS .iter() .copied() - .filter(|k| self.scalar(k).is_none()) + .filter(|k| self.scalar(k).is_none() && self.list(k).is_none()) + .collect() + } + + /// Which of [`REQUIRED_SCALAR_KEYS`] this block does carry. + /// + /// The complement of [`Self::missing_required`], for callers that want to + /// say what a block has rather than only what it lacks. + pub fn present_required(&self) -> Vec<&'static str> { + REQUIRED_SCALAR_KEYS + .iter() + .copied() + .filter(|k| self.scalar(k).is_some() || self.list(k).is_some()) .collect() } @@ -235,9 +302,10 @@ fn uncomment(body: &[String]) -> Result { let mut out = String::new(); for (i, line) in body.iter().enumerate() { let trimmed = line.trim_start(); - let Some(rest) = trimmed.strip_prefix('#') else { + let Some(rest) = trimmed.strip_prefix(COMMENT_PREFIX) else { bail!( - "line {} of the embedded deed block is not a `#` comment line: {:?}", + "line {} of the embedded deed block is not a `{COMMENT_PREFIX}` \ + comment line: {:?}", i + 1, line ); @@ -277,9 +345,9 @@ fn parse_deed_body( fn flatten_praxis_deed( node: &deed::Node, ) -> Result<(Vec<(String, String)>, Vec<(String, Vec)>)> { - if node.head != "praxis-deed" { + if node.head != DEED_HEAD { bail!( - "an embedded `@launcher-deed` block must be a `praxis-deed`, got `{}`", + "an embedded `@launcher-deed` block must be a `{DEED_HEAD}`, got `{}`", node.head ); } @@ -365,9 +433,61 @@ fn flatten_praxis_deed( )); } + // The four declarations the standard has always required and no + // launcher has ever carried until now (#41). Each is optional here — + // `missing_required` is what enforces them, and it reports their absence + // rather than refusing the block outright, so a launcher minted before + // this change still reads. + push_list(&mut lists, node, "modes", "modes", "accepted"); + push_list(&mut lists, node, "platforms", "platforms", "supported"); + push_list( + &mut lists, + node, + "lifecycle-phases", + "lifecycle-phases-covered", + "covered", + ); + push_list( + &mut lists, + node, + "lifecycle-phases", + "lifecycle-phases-deferred", + "deferred", + ); + Ok((scalars, lists)) } +/// Copy one declared list out of an optional clause into the flat list map. +/// +/// `clause_head` is the deed clause, `keyword` the field inside it, and +/// `flat_key` the name every other part of this tool knows the list by — the +/// one the standard's `:required-fields` uses. +fn push_list( + out: &mut Vec<(String, Vec)>, + node: &deed::Node, + clause_head: &str, + flat_key: &str, + keyword: &str, +) { + let Some(value) = node.clause(clause_head).and_then(|c| c.field(keyword)) else { + return; + }; + let Some(raw) = value.as_list() else { + return; + }; + let strs = value.str_list(); + // Non-strings are skipped rather than erroring: the clause is absent as + // far as this block is concerned, and `missing_required` will say so. + if strs.len() != raw.len() { + return; + } + out.push(( + flat_key.to_string(), + strs.into_iter().map(|s| s.to_string()).collect(), + )); +} + /// Push `key` only when the deed actually carried a value for it. /// /// A free function rather than a closure so it can borrow `out` @@ -475,12 +595,12 @@ fn parse_body(raw_lines: &[String]) -> Result<(Vec<(String, String)>, Vec<(Strin /// Strip the leading `# ` (or `#`) that every metadata line carries. fn strip_comment_prefix(line: &str) -> &str { let trimmed = line.trim_start(); - if let Some(rest) = trimmed.strip_prefix("# ") { - rest - } else if let Some(rest) = trimmed.strip_prefix('#') { - rest - } else { - trimmed + // The space after the `#` is part of the prefix: stripping only the + // character would leave every line a space deeper than the block wrote + // it, which is invisible in output and wrong in error columns. + match trimmed.strip_prefix(COMMENT_PREFIX) { + Some(rest) => rest.strip_prefix(' ').unwrap_or(rest), + None => trimmed, } } @@ -586,25 +706,58 @@ pub fn rewrite_scalar(text: &str, key: &str, new_value: &str) -> Result #[cfg(test)] mod tests { use super::*; + use crate::standard::LauncherStandard; const SAMPLE: &str = r#"#!/usr/bin/env bash # SPDX-License-Identifier: MPL-2.0 # # @a2ml-metadata begin # ( -# id = "burble-launcher" +# id = "stapeln-launcher" # type = "launcher" # version = "0.1.0" -# app-name = "burble" -# app-display = "Burble" -# app-url = "http://localhost:4020" +# app-name = "stapeln" +# app-display = "Stapeln" +# app-url = "http://localhost:4010" # runtime-kind = "server-url" # standards-compliance = [ # "launcher-standard.adoc" # "LM-LA-LIFECYCLE-STANDARD.adoc" +# "cross-platform-system-integration-modes" # ] -# standard-spec-version = "0.1.0" +# standard-spec-version = "0.4.0" # generator = "launch-scaffolder" +# modes = [ +# "--start" +# "--stop" +# "--status" +# "--browser" +# "--web" +# "--auto" +# "--integ" +# "--disinteg" +# "--help" +# ] +# platforms = [ +# "linux" +# "macos" +# "windows" +# ] +# lifecycle-phases-covered = [ +# "start" +# "stop" +# "status" +# "integ" +# "disinteg" +# ] +# lifecycle-phases-deferred = [ +# "install" +# "uninstall" +# "update" +# "backup" +# "restore" +# "migrate" +# ] # ) # @a2ml-metadata end # @@ -614,13 +767,13 @@ echo "not the block" #[test] fn parses_scalars_and_lists() { let block = parse_from_text(SAMPLE).unwrap().unwrap(); - assert_eq!(block.scalar("id"), Some("burble-launcher")); + assert_eq!(block.scalar("id"), Some("stapeln-launcher")); assert_eq!(block.scalar("version"), Some("0.1.0")); - assert_eq!(block.scalar("app-name"), Some("burble")); + assert_eq!(block.scalar("app-name"), Some("stapeln")); assert_eq!(block.scalar("runtime-kind"), Some("server-url")); assert_eq!(block.scalar("generator"), Some("launch-scaffolder")); let compliance = block.list("standards-compliance").unwrap(); - assert_eq!(compliance.len(), 2); + assert_eq!(compliance.len(), 3); assert_eq!(compliance[0], "launcher-standard.adoc"); } @@ -731,7 +884,13 @@ echo "not the block" # (compliance :standard-version "0.4.0" # :standards ("launcher-standard.adoc" # "LM-LA-LIFECYCLE-STANDARD.adoc" -# "cross-platform-system-integration-modes"))) +# "cross-platform-system-integration-modes")) +# (modes :accepted ("--start" "--stop" "--status" "--browser" "--web" +# "--auto" "--integ" "--disinteg" "--help")) +# (platforms :supported ("linux" "macos" "windows")) +# (lifecycle-phases :covered ("start" "stop" "status" "integ" "disinteg") +# :deferred ("install" "uninstall" "update" +# "backup" "restore" "migrate"))) # @launcher-deed end echo hi @@ -772,10 +931,283 @@ echo hi ][..] ) ); + + // ⚠ This artefact does NOT satisfy every required field, and that is + // now asserted rather than smoothed over. It was minted on 2026-09-22 + // by an emitter that predates four of the standard's requirements, + // which the guard of the day did not check for — the defect #41 was + // filed about. It still PARSES, and every value it carries still + // reads: that is the backwards-compatibility promise, and it is what + // this test is for. The four it lacks are named here so that a future + // change to the guard, the standard or this artefact has to say so. + assert_eq!( + block.missing_required(), + vec![ + "modes", + "platforms", + "lifecycle-phases-covered", + "lifecycle-phases-deferred" + ], + "a pre-phase launcher is missing exactly the four declarations the \ + pre-phase emitter never emitted (#41)" + ); + assert_eq!( + block.present_required(), + vec![ + "id", + "type", + "version", + "app-name", + "app-display", + "app-url", + "standards-compliance" + ], + "and carries all seven of the standard's required fields that existed \ + as emitted values" + ); + } + + // ---------------------------------------------------------------- #41 + // The guard against the standard, and the standard against the guard. + // ---------------------------------------------------------------- + + /// The key set this module enforces is the standard's own, read from the + /// vendored deed — not a list that happens to have agreed with it once. + /// + /// Non-vacuity, stated rather than assumed: the assertion is an equality + /// over ELEVEN names drawn from two files, and [`the_old_guard_passed_a_ + /// block_missing_four_required_fields`] below shows a real committed + /// artefact that satisfies the old list and fails this one. Without that + /// second test this one is a tautology with extra steps. + #[test] + fn required_keys_are_exactly_the_standard_required_fields() { + let std_ = LauncherStandard::baked().expect("baked standard loads"); + let from_deed = std_ + .metadata_required_fields() + .expect("the standard declares its required metadata fields"); + + assert_eq!( + REQUIRED_SCALAR_KEYS, + from_deed.as_slice(), + "the guard and the standard disagree; one of them must move (#41)" + ); + + // Four of these were required by the standard all along and were not + // checked. Named individually so that a future edit which drops one + // has to drop its name here too. + for key in [ + "app-url", + "modes", + "platforms", + "lifecycle-phases-covered", + "lifecycle-phases-deferred", + ] { + assert!( + REQUIRED_SCALAR_KEYS.contains(&key), + "`{key}` is a required field the guard does not enforce" + ); + } + } + + /// The pre-#41 guard accepted a launcher that is missing four required + /// fields. A committed artefact proves it. + /// + /// `OLD_REQUIRED_KEYS` is the list this module carried before #41, kept + /// here as a literal so the claim stays checkable after the constant + /// moved on. The frozen 2026-09-22 launcher satisfies every one of those + /// keys — it was, after all, minted and accepted as complete — and it + /// does not satisfy the standard. That contradiction is the defect, and + /// this test is what keeps it from being reintroduced: if the guard ever + /// slips back towards the old list, a block that passes it will fail the + /// standard again and this assertion goes red. + #[test] + fn the_old_guard_passed_a_block_missing_four_required_fields() { + const OLD_REQUIRED_KEYS: &[&str] = &[ + "id", + "type", + "version", + "app-name", + "app-display", + "runtime-kind", + "standard-spec-version", + "generator", + ]; + + // The two lists differ, or the comparison below proves nothing. + assert_ne!( + OLD_REQUIRED_KEYS, REQUIRED_SCALAR_KEYS, + "vacuity: the old and new key sets are identical" + ); + + let text = std::fs::read_to_string(LEGACY_FIXTURE) + .unwrap_or_else(|e| panic!("reading {LEGACY_FIXTURE}: {e}")); + let block = parse_from_text(&text) + .expect("a pre-phase launcher still parses") + .expect("a pre-phase launcher carries a block"); + + for key in OLD_REQUIRED_KEYS { + assert!( + block.scalar(key).is_some(), + "the old guard required `{key}`, and this artefact carries it — \ + otherwise it proves nothing about the old guard" + ); + } + assert_eq!( + block.missing_required().len(), + 4, + "a launcher the old guard called complete is missing four of the \ + standard's required fields; that is the defect #41 reports" + ); + } + + /// The three keys the old guard demanded and the standard does not are + /// advisory now: a launcher without them reads as conformant. + /// + /// `hyperpolymath/trigger`'s launcher is that launcher. It carries all + /// eleven fields the standard requires and none of the three this tool + /// invented, and every release of this tool has called it invalid while + /// the standard called it conformant (#41). Stripping each key in turn + /// from a complete block is the general statement of that case, not just + /// the one instance of it. + #[test] + fn the_three_keysthat_are_not_in_the_standard_are_advisory() { + for key in ADVISORY_SCALAR_KEYS { + assert!( + !REQUIRED_SCALAR_KEYS.contains(key), + "`{key}` is both required and advisory" + ); + assert!( + SAMPLE.contains(&format!("{key} ")), + "vacuity: `{key}` is not in SAMPLE, so stripping it proves nothing" + ); + + let stripped: String = SAMPLE + .lines() + .filter(|l| !l.trim_start().starts_with(&format!("# {key}"))) + .collect::>() + .join("\n"); + let block = parse_from_text(&stripped) + .expect("a block without an advisory key still parses") + .expect("a block without an advisory key is still a block"); + + assert!( + block.scalar(key).is_none(), + "vacuity: `{key}` survived the strip, so the test below is not testing it" + ); + assert_eq!( + block.missing_required(), + Vec::<&'static str>::new(), + "a block without the advisory key `{key}` must still read as conformant" + ); + } + } + + /// The markers and the field syntax are the ones the standard declares. + /// + /// Before #41 the block's encoding lived only in this file, so a launcher + /// could satisfy every requirement in the standard and still be + /// unreadable by the tool the standard names as its consumer. Both sides + /// now have to agree, and this is where the disagreement surfaces. + #[test] + fn the_block_encoding_is_the_one_the_deed_declares() { + let std_ = LauncherStandard::baked().expect("baked standard loads"); + let declared = |keyword: &str| { + std_.metadata_encoding(keyword) + .unwrap_or_else(|e| panic!("the standard declares no `{keyword}`: {e}")) + }; + + assert_eq!( + DEED_BEGIN, + declared("marker-begin"), + "the parser and the standard disagree on the block's opening marker" + ); + assert_eq!(DEED_END, declared("marker-end")); + assert_eq!( + LEGACY_BEGIN, + declared("retired-marker-begin"), + "the retired markers are what keeps every launcher minted before \ + 2026-09-23 readable, and they must stay declared" + ); + assert_eq!(LEGACY_END, declared("retired-marker-end")); + assert_eq!(COMMENT_PREFIX, declared("comment-prefix")); + assert_eq!(DEED_HEAD, declared("head")); + assert_eq!( + "s-expression", + declared("syntax"), + "the block's field syntax is `(clause :key value)`; a `key = value` \ + block is not DEED and the grammar will not read it" + ); + } + + /// …and the declared encoding is not merely string-equal to the + /// constants: a block written from the deed's own declared strings is + /// one this parser reads. + /// + /// Without this, `the_block_encoding_is_the_one_the_deed_declares` is an + /// equality between two strings this crate owns, which can hold while + /// the standard's copy of them describes an encoding nothing implements. + #[test] + fn a_block_written_from_the_declared_encoding_is_one_this_parser_reads() { + let std_ = LauncherStandard::baked().expect("baked standard loads"); + let declared = |keyword: &str| { + std_.metadata_encoding(keyword) + .unwrap_or_else(|e| panic!("the standard declares no `{keyword}`: {e}")) + }; + let (begin, end) = (declared("marker-begin"), declared("marker-end")); + let (prefix, head, syntax) = ( + declared("comment-prefix"), + declared("head"), + declared("syntax"), + ); + assert_eq!( + syntax, "s-expression", + "this test builds an s-expression block, so it proves nothing about \ + any other declared syntax" + ); + + // The SPDX header is the DEED grammar's rule for every document, not + // this block's encoding, so it is not in the encoding clause — the + // emitter writes it for the same reason `;;` is required elsewhere. + let body = [ + ";; SPDX-License-Identifier: MPL-2.0".to_string(), + format!("({head}"), + " :schema-version \"1.0.0\"".to_string(), + " :canonical-name \"declared-encoding\"".to_string(), + " :beholding-chora #u5\"estate/chora\"".to_string(), + " (artefact :type \"launcher\" :version \"0.1.0\"".to_string(), + " :generator \"launch-scaffolder\")".to_string(), + " (app :name \"x\" :display \"X\"".to_string(), + " :url \"http://localhost:1\" :runtime-kind \"server-url\")".to_string(), + " (compliance :standard-version \"0.4.0\"".to_string(), + " :standards (\"launcher-standard.adoc\"))".to_string(), + " (modes :accepted (\"--start\" \"--stop\"))".to_string(), + " (platforms :supported (\"linux\"))".to_string(), + " (lifecycle-phases :covered (\"start\")".to_string(), + " :deferred (\"install\")))".to_string(), + ]; + // The markers carry the comment prefix themselves — that is what a + // marker line looks like in a shell script; the prefix is declared + // separately because the BODY lines carry it too and those have no + // marker to hide it behind. + let script = format!( + "#!/usr/bin/env bash\n{begin}\n{}\n{end}\necho hi\n", + body.iter() + .map(|l| format!("{prefix} {l}")) + .collect::>() + .join("\n") + ); + + let block = parse_from_text(&script) + .expect("a block written from the declared encoding parses") + .expect("a block written from the declared encoding is found"); + + assert!(block.is_deed(), "the declared markers are the deed dialect"); + assert_eq!(block.scalar("app-name"), Some("x")); + assert_eq!(block.list("platforms"), Some(&["linux".to_string()][..])); assert_eq!( block.missing_required(), Vec::<&'static str>::new(), - "the fixture must satisfy every required key" + "a block written exactly as the standard declares satisfies it" ); } @@ -793,14 +1225,20 @@ echo hi assert_eq!(block.missing_required(), Vec::<&'static str>::new()); } - /// The compat guarantee, stated as an equality rather than as two - /// separate lists of assertions: every existing caller reads through - /// `scalars` / `lists`, so if those match, no caller can tell the - /// dialects apart. + /// The two dialects flatten to the same values. + /// + /// Measured on the SAME launcher written both ways ([`SAMPLE`] and + /// [`DEED_SAMPLE`]), which is what makes the equality meaningful: any + /// difference is a dialect bug rather than a difference between two + /// launchers. It used to compare the frozen 2026-09-22 fixture against + /// the deed sample, which stopped being a like-for-like comparison once + /// #41 gave the deed sample four declarations the pre-phase emitter never + /// emitted; the fixture has its own test + /// ([`the_committed_legacy_fixture_still_parses`]) and its own stated + /// difference from a modern block. #[test] fn both_dialects_flatten_to_the_same_values() { - let legacy_text = std::fs::read_to_string(LEGACY_FIXTURE).unwrap(); - let legacy = parse_from_text(&legacy_text).unwrap().unwrap(); + let legacy = parse_from_text(SAMPLE).unwrap().unwrap(); let deed_block = parse_from_text(DEED_SAMPLE).unwrap().unwrap(); assert_eq!( diff --git a/crates/launcher-common/src/standard.rs b/crates/launcher-common/src/standard.rs index b47eb0d..0fdfe46 100644 --- a/crates/launcher-common/src/standard.rs +++ b/crates/launcher-common/src/standard.rs @@ -176,6 +176,107 @@ impl LauncherStandard { ladder_from(search, env) } + + // --------------------------------------------------------------- + // The metadata block, as the STANDARD states it (#41) + // --------------------------------------------------------------- + + /// The `(metadata-block :required-fields …)` list, in the standard's own + /// order. + /// + /// The guard in [`crate::metadata_block`] is asserted equal to this, so + /// the standard is the source and the Rust list is the copy that has to + /// keep up — not the other way round. + pub fn metadata_required_fields(&self) -> Result> { + let Some(block) = self.doc.clause("metadata-block") else { + anyhow::bail!("standard is missing the (metadata-block) clause"); + }; + let Some(value) = block.field("required-fields") else { + anyhow::bail!("(metadata-block) is missing :required-fields"); + }; + str_list_of(value, "(metadata-block :required-fields …)") + } + + /// One string field of the `(metadata-block (encoding …))` clause. + /// + /// The encoding clause is what makes "conformant with the standard" and + /// "readable by launch-scaffolder" the same property: the parser's marker + /// constants are asserted equal to these strings, so a change on either + /// side becomes a failing test rather than a silent stranding of every + /// launcher already on disk (#41 AC3). + pub fn metadata_encoding(&self, keyword: &str) -> Result { + let Some(block) = self.doc.clause("metadata-block") else { + anyhow::bail!("standard is missing the (metadata-block) clause"); + }; + let Some(encoding) = block.clause("encoding") else { + anyhow::bail!( + "(metadata-block) is missing its (encoding) clause; without it the \ + delimiter and field syntax live only in this tool's parser (#41)" + ); + }; + encoding + .str_field(keyword) + .map(str::to_string) + .with_context(|| format!("(metadata-block (encoding …)) is missing :{keyword}")) + } + + /// The platforms a compliant launcher handles, from `(platforms …)`. + /// + /// The standard requires every launcher to declare `platforms`; this is + /// where that set is named, once, rather than invented per launcher. + pub fn platforms(&self) -> Result> { + let Some(clause) = self.doc.clause("platforms") else { + anyhow::bail!("standard is missing the (platforms) clause"); + }; + let Some(value) = clause.field("supported") else { + anyhow::bail!("(platforms) is missing :supported"); + }; + str_list_of(value, "(platforms :supported …)") + } + + /// The lifecycle phases a launcher covers, from `(lifecycle-phases …)`. + pub fn lifecycle_phases_covered(&self) -> Result> { + self.lifecycle_phases("covered") + } + + /// The lifecycle phases a launcher defers to the provisioning layer. + pub fn lifecycle_phases_deferred(&self) -> Result> { + self.lifecycle_phases("deferred") + } + + fn lifecycle_phases(&self, keyword: &str) -> Result> { + let Some(clause) = self.doc.clause("lifecycle-phases") else { + anyhow::bail!("standard is missing the (lifecycle-phases) clause"); + }; + let Some(value) = clause.field(keyword) else { + anyhow::bail!("(lifecycle-phases) is missing :{keyword}"); + }; + str_list_of(value, &format!("(lifecycle-phases :{keyword} …)")) + } +} + +/// Every element of a list value, as owned strings, or an error naming the +/// clause that was malformed. +/// +/// [`crate::deed::Value::str_list`] silently skips non-strings; a declared +/// set must not be silently trimmed, so the counts are compared here and a +/// non-string is reported rather than dropped. +fn str_list_of(value: &deed::Value, what: &str) -> Result> { + let Some(items) = value.as_list() else { + anyhow::bail!("{what} must be a list"); + }; + let strs = value.str_list(); + if strs.len() != items.len() { + anyhow::bail!( + "{what} must hold only strings; {} of {} entries are not", + items.len() - strs.len(), + items.len() + ); + } + if strs.is_empty() { + anyhow::bail!("{what} is empty; a required field with no members is no claim at all"); + } + Ok(strs.into_iter().map(str::to_string).collect()) } /// Read one `*-search` clause's rungs into concrete paths. @@ -499,12 +600,30 @@ mod tests { /// and no test naming the change. The pin makes that edit announce /// itself. The digest is sha256 of the file as vendored from /// `hyperpolymath/standards` (git blob `c751c4ec`); update both together. + /// + /// ⚠ **This copy has deliberately diverged from canon**, and the pin is + /// what records it. Blob `c751c4ec` is still the last vendored state; + /// on top of it this file now carries three clauses the canon does not + /// have yet, added for #41: + /// + /// * `(platforms :supported …)` and `(lifecycle-phases …)` — the value + /// domains behind four names in `(metadata-block :required-fields)`. + /// The standard demanded those fields without ever saying what they + /// may contain, which made the requirement unfalsifiable. + /// * `(metadata-block (encoding …))` — the marker pair, the comment + /// prefix and the field syntax, which until now lived only in + /// `metadata_block.rs`, so a launcher could satisfy every requirement + /// in the standard and still be unreadable by this tool. + /// + /// They are upstreamed as `hyperpolymath/standards` PRs; until those + /// land, `diff` against canon reports three added clauses and nothing + /// else. Bump the pin and this comment together if either side moves. #[test] fn the_vendored_standard_is_pinned_by_content() { use sha2::{Digest, Sha256}; let got = format!("{:x}", Sha256::digest(BAKED_STANDARD.as_bytes())); assert_eq!( - got, "73dd64f3d2a2282c9bfee06acf4a1d511bcc7ded5c7b30dcf15b6f85c8e4f871", + got, "8f55bcbbd06a7a8dd78ebff532af32009b7fc6cc7f07064fdef7d0402ad5cefe", "standards/launcher-standard_praxis.deed changed; re-vendor deliberately \ and update this pin in the same commit" ); diff --git a/crates/launcher-common/src/template.rs b/crates/launcher-common/src/template.rs index 280c79a..2b61994 100644 --- a/crates/launcher-common/src/template.rs +++ b/crates/launcher-common/src/template.rs @@ -26,6 +26,45 @@ pub const LAUNCHER_TEMPLATE: &str = include_str!("../../../templates/launcher.sh /// `launch-scaffolder provision`. Passing `None` leaves that value empty. /// Rendering fails if a value emitted into the DEED block contains a /// control character with no legal DEED string spelling. +/// The mode flags the generated script's main switch implements. +/// +/// This is the launcher's own mode surface, so it lives next to the template +/// rather than in the standard's `(required-modes)` clause: that clause says +/// what a compliant launcher MUST accept, and this says what this one DOES +/// accept. They are not the same, and conflating them would let the block +/// claim a mode the script does not implement (the standard also requires +/// `--version`, which this script does not yet have — a live finding, not +/// something to paper over by copying the standard's list). +/// +/// [`tests::the_declared_modes_are_the_arms_of_the_main_switch`] asserts this +/// list and the template's `case "$MODE"` arms agree in both directions, so +/// the claim cannot drift from the script. +pub const LAUNCHER_MODES: &[&str] = &[ + "--start", + "--stop", + "--status", + "--browser", + "--web", + "--auto", + "--integ", + "--disinteg", + "--help", +]; + +/// Render a list of strings as the inside of a DEED list: `"a" "b" "c"`. +/// +/// Each item goes through [`deed_escape`], the same filter the template +/// applies to every other value emitted into the block. Values that arrive +/// from the standard are not necessarily inert — a platform name holding a +/// `"` would close the string early and make the whole block unparseable. +fn deed_list(values: &[String]) -> Result { + let mut out = Vec::with_capacity(values.len()); + for v in values { + out.push(format!("\"{}\"", deed_escape(v).map_err(tera::Error::msg)?)); + } + Ok(out.join(" ")) +} + pub fn render( config: &LauncherConfig, _standard: &LauncherStandard, @@ -122,17 +161,43 @@ pub fn render( } ctx.insert("url", &url_string); - // PID / log file defaults follow the standard's pattern when unset. - let pid_file = config - .runtime - .pid_file - .clone() - .unwrap_or_else(|| format!("/tmp/{}-server.pid", config.project.name)); - let log_file = config - .runtime - .log_file - .clone() - .unwrap_or_else(|| format!("/tmp/{}-server.log", config.project.name)); + // PID / log file defaults, per-user rather than in a world-writable + // directory. + // + // The previous defaults were `/tmp/-server.pid` and + // `/tmp/-server.log`: world-writable AND predicted entirely by the + // project name, so any local user could create or symlink the path before + // the launcher's first run and steer the `kill` / `rm` the script later + // performs on it (`is_running`, `clear_stale_pid`, `stop_server`). That is + // Hypatia alerts 82 and 83 (#48). + // + // Both defaults are therefore SHELL expressions, not paths resolved here: + // + // * `$XDG_RUNTIME_DIR` for the pid, because the pid is per-session state + // and the runtime directory is already per-user and 0700. It falls + // back to `$XDG_STATE_HOME` and then to `~/.local/state`, per the XDG + // base directory spec, so the launcher still works on a host with no + // runtime directory (cron, containers, a bare tty). + // * `$XDG_STATE_HOME` for the log, because a log must survive a logout — + // which is precisely what `$XDG_RUNTIME_DIR` does not promise. + // `mktemp` is deliberately not used for either: an unpredictable name + // is unusable for a pid file that another invocation has to find. + // + // Resolving them at mint time instead would bake one machine's paths into + // a script that may run on another, so the expansion is left to the shell + // and the directory is created by the script before first write. + let pid_file = config.runtime.pid_file.clone().unwrap_or_else(|| { + format!( + "${{XDG_RUNTIME_DIR:-${{XDG_STATE_HOME:-$HOME/.local/state}}}}/{}-server.pid", + config.project.name + ) + }); + let log_file = config.runtime.log_file.clone().unwrap_or_else(|| { + format!( + "${{XDG_STATE_HOME:-$HOME/.local/state}}/{}-server.log", + config.project.name + ) + }); ctx.insert("pid_file", &pid_file); ctx.insert("log_file", &log_file); ctx.insert("wait_seconds", &config.runtime.wait_for_url_timeout_seconds); @@ -158,6 +223,46 @@ pub fn render( // --- metadata ----------------------------------------------------- ctx.insert("spec_version", &_standard.spec_version); + // The four declarations the standard's `(metadata-block + // :required-fields)` has always demanded and `mint` never emitted (#41). + // The platforms and the lifecycle phases are the standard's own + // vocabulary, read from its clauses rather than restated here; the modes + // are this template's, from [`LAUNCHER_MODES`]. + // + // Anything that cannot be read out of the standard is an error rather + // than an empty list: a block that declares `()` for `platforms` would + // satisfy a presence check while claiming nothing. + ctx.insert( + "modes", + &deed_list( + &LAUNCHER_MODES + .iter() + .map(|m| m.to_string()) + .collect::>(), + )?, + ); + ctx.insert( + "platforms", + &deed_list(&_standard.platforms().context( + "the standard carries no (platforms) clause, so the launcher cannot \ + declare the `platforms` field its metadata block requires", + )?)?, + ); + ctx.insert( + "lifecycle_phases_covered", + &deed_list(&_standard.lifecycle_phases_covered().context( + "the standard carries no (lifecycle-phases :covered …), so the launcher \ + cannot declare the `lifecycle-phases-covered` field its block requires", + )?)?, + ); + ctx.insert( + "lifecycle_phases_deferred", + &deed_list(&_standard.lifecycle_phases_deferred().context( + "the standard carries no (lifecycle-phases :deferred …), so the launcher \ + cannot declare the `lifecycle-phases-deferred` field its block requires", + )?)?, + ); + // Absolute path back to the source config, so the generated // script's --integ / --disinteg arms can delegate to // `launch-scaffolder provision --integ "$CONFIG_FILE"`. @@ -343,9 +448,39 @@ mod tests { legacy.scalars, minted.scalars, "the deed emitter changed a scalar the legacy block carried" ); + + // The lists need stating rather than comparing wholesale, because + // #41 taught the emitter four declarations the 2026-09-22 emitter did + // not make. Comparing `legacy.lists == minted.lists` would either fail + // (hiding a real drift behind a known one) or, if "fixed" by trimming + // the new entries, stop noticing a change to `standards-compliance`. + // + // So: every list the pre-phase launcher carries must be carried + // identically, no scalar may appear or vanish, and the ONLY addition + // may be the four declarations the standard has always required. + for (key, values) in &legacy.lists { + assert_eq!( + minted.list(key), + Some(values.as_slice()), + "the deed emitter changed list `{key}`" + ); + } + let added: Vec<&String> = minted + .lists + .iter() + .filter(|(k, _)| legacy.list(k).is_none()) + .map(|(k, _)| k) + .collect(); assert_eq!( - legacy.lists, minted.lists, - "the deed emitter changed a list the legacy block carried" + added, + vec![ + "modes", + "platforms", + "lifecycle-phases-covered", + "lifecycle-phases-deferred" + ], + "the only lists today's mint may add to a pre-phase launcher are the four \ + declarations #41 taught it to emit" ); } @@ -531,4 +666,275 @@ mod tests { ); assert!(script.contains("URL=\"http://localhost:4010\"")); } + + // --------------------------------------------------------------- + // #48 — the default pid/log location is not a predictable path in a + // world-writable directory. + // --------------------------------------------------------------- + + /// The DEFAULT pid/log paths, spelled out as literals. + /// + /// Written as literal strings on purpose. #48 AC4 forbids asserting them + /// by recomputing the same `format!` the renderer uses: an equality whose + /// right-hand side is derived from the left cannot fail when both move + /// together, so such a test would have passed happily while the default sat + /// in `/tmp`. The fixture config sets neither value (see + /// `stapeln.launcher.fixture.a2ml`), so these are defaults, not overrides. + /// + /// They are shell expressions rather than paths: the launcher runs on the + /// user's machine, which is not necessarily the machine it was minted on. + const DEFAULT_PID_LINE: &str = + "PID_FILE=\"${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}}/stapeln-server.pid\""; + const DEFAULT_LOG_LINE: &str = + "LOG_FILE=\"${XDG_STATE_HOME:-$HOME/.local/state}/stapeln-server.log\""; + + /// A config that sets neither `pid-file` nor `log-file` mints a launcher + /// whose state lands under a per-user directory — never in `/tmp`. + #[test] + fn default_pid_and_log_paths_are_per_user_not_world_writable() { + let cfg = stapeln_config(); + assert!( + cfg.runtime.pid_file.is_none() && cfg.runtime.log_file.is_none(), + "the fixture config must set neither, or this asserts nothing" + ); + let script = render_stapeln(); + + assert!( + script.contains(DEFAULT_PID_LINE), + "the default pid line changed; it must stay out of world-writable space. \\ + Looking for: {DEFAULT_PID_LINE}" + ); + assert!( + script.contains(DEFAULT_LOG_LINE), + "the default log line changed; it must stay out of world-writable space. \\ + Looking for: {DEFAULT_LOG_LINE}" + ); + + // The finding, stated directly: no `/tmp` path is emitted by default. + for line in script.lines() { + if line.starts_with("PID_FILE=") || line.starts_with("LOG_FILE=") { + assert!( + !line.contains("/tmp/"), + "`{line}` puts launcher state in world-writable /tmp with a name \\ + predictable from the project (#48)" + ); + } + } + } + + /// An explicit `pid-file` / `log-file` in the config still wins, unchanged + /// (#48 AC2). + /// + /// Including the `~/` spelling, which is expanded by `integration.rs` + /// rather than by the renderer. + #[test] + fn explicit_pid_and_log_paths_still_win_unchanged() { + let std_ = LauncherStandard::baked().expect("baked standard should parse"); + let mut cfg = stapeln_config(); + cfg.runtime.pid_file = Some("/var/run/stapeln.pid".into()); + cfg.runtime.log_file = Some("~/logs/stapeln.log".into()); + + let script = render(&cfg, &std_, None).expect("renders"); + + assert!( + script.contains("PID_FILE=\"/var/run/stapeln.pid\""), + "an explicit pid-file must be emitted verbatim" + ); + assert!( + script.contains("LOG_FILE=\"~/logs/stapeln.log\""), + "an explicit log-file must be emitted verbatim, `~` included" + ); + assert!( + !script.contains("XDG_RUNTIME_DIR") && !script.contains("XDG_STATE_HOME"), + "the XDG defaults must not appear when the config states both paths" + ); + // The directory-creation helper is unconditional: it also has to work + // for an explicit path the user has not created yet. + assert!( + script.contains("ensure_state_dirs"), + "state directories must be created whatever path the config chose" + ); + } + + /// The generated launcher creates its state directories 0700 before writing + /// (#48 AC3). + /// + /// Two assertions, because either alone is vacuous: `mkdir -p` without a + /// mode creates the directory with the caller's umask (commonly 0755), and + /// `mkdir -p -m` applies the mode only to the deepest directory it creates + /// — so the mode is stated separately, and the test looks for both halves. + #[test] + fn the_launcher_creates_its_state_directories_0700_before_writing() { + let script = render_stapeln(); + + assert!( + script.contains("chmod 0700 \"$pid_dir\" \"$log_dir\""), + "the launcher must set 0700 on the directories it is about to write into" + ); + assert!( + script.contains("mkdir -p \"$pid_dir\" \"$log_dir\""), + "the launcher must create the directories it is about to write into" + ); + } + + /// `ensure_state_dirs` runs BEFORE the first write, not after. + /// + /// The previous test pins what the helper does; this one pins the ordering + /// property that makes it a fix rather than a decoration: a directory + /// created after the pid file is written is no protection at all. + #[test] + fn state_dirs_are_ensured_before_the_first_pid_write() { + let script = render_stapeln(); + let start = script + .find("start_server()") + .expect("start_server is defined in the template"); + let body = &script[start..]; + let ensure = body + .find("ensure_state_dirs") + .expect("start_server must ensure its state dirs before writing"); + let write = body + .find(">\"$LOG_FILE\"") + .expect("start_server writes the log"); + assert!( + ensure < write, + "`ensure_state_dirs` must run before the launcher writes $LOG_FILE" + ); + } + + /// Every mode flag the template's main switch handles. + /// + /// Read out of [`LAUNCHER_TEMPLATE`] rather than restated, so the block's + /// `modes` declaration is pinned to the script's actual behaviour instead + /// of to a list that happens to match today. + fn main_switch_arms() -> Vec { + let mut arms = Vec::new(); + let mut in_switch = false; + for line in LAUNCHER_TEMPLATE.lines() { + let t = line.trim(); + if t.starts_with("case \"$MODE\" in") { + in_switch = true; + continue; + } + if in_switch && t == "esac" { + break; + } + if !in_switch { + continue; + } + // An arm is a pattern list followed by `)`. Bodies are indented + // commands, tera tags, and `;;` terminators — none of which start + // with `--` or `*`. + if !(t.starts_with("--") || t.starts_with('*')) { + continue; + } + let Some(patterns) = t.split(')').next() else { + continue; + }; + for p in patterns.split('|') { + arms.push(p.trim().to_string()); + } + } + arms + } + + /// The `modes` the block declares are the modes the script implements, in + /// both directions (#41). + /// + /// A one-directional check would be satisfiable by declaring modes the + /// script does not have (the block claims a surface it does not offer) or + /// by implementing modes it does not declare (the standard's required + /// field under-reports). Both are checked, and the wildcard `*)` and the + /// `-h` alias are excluded by name rather than silently — a new arm that + /// is neither must be declared here or the test fails. + /// + /// Vacuity guard: the arms are read from the template, so if the main + /// switch were ever renamed or removed the extraction returns nothing and + /// the test fails instead of passing on an empty list. + #[test] + fn the_declared_modes_are_the_arms_of_the_main_switch() { + let arms = main_switch_arms(); + assert!( + arms.len() >= LAUNCHER_MODES.len(), + "vacuity: the template's main switch was not found; found {arms:?}" + ); + + for mode in LAUNCHER_MODES { + assert!( + arms.iter().any(|a| a == mode), + "`mint` declares `{mode}`, but the template's main switch has no such \ + arm — the block would claim a mode the script does not implement" + ); + } + + let undeclared: Vec<&String> = arms + .iter() + .filter(|a| a.as_str() != "*" && a.as_str() != "-h") + .filter(|a| !LAUNCHER_MODES.contains(&a.as_str())) + .collect(); + assert_eq!( + undeclared, + Vec::<&String>::new(), + "the main switch handles arms the block does not declare; add them to \ + LAUNCHER_MODES so the launcher's declared surface is complete" + ); + } + + /// The emitted block declares the four fields the standard has always + /// required and no launcher carried until #41. + /// + /// Values are checked here as well as presence: a `platforms` field is a + /// claim about the world, and an empty or invented list would satisfy a + /// presence check while saying nothing. + #[test] + fn a_minted_launcher_declares_the_four_fields_it_never_used_to_carry() { + let block = crate::metadata_block::parse_from_text(&render_stapeln()) + .expect("parses") + .expect("has a block"); + + let declared_modes: Vec = LAUNCHER_MODES.iter().map(|m| m.to_string()).collect(); + assert_eq!( + block.list("modes"), + Some(declared_modes.as_slice()), + "the declared modes must be exactly the modes the script implements" + ); + + let std_ = LauncherStandard::baked().expect("baked standard loads"); + assert_eq!( + block.list("platforms"), + Some( + std_.platforms() + .expect("the standard declares platforms") + .as_slice() + ) + ); + assert_eq!( + block.list("lifecycle-phases-covered"), + Some( + std_.lifecycle_phases_covered() + .expect("the standard declares the covered phases") + .as_slice() + ) + ); + assert_eq!( + block.list("lifecycle-phases-deferred"), + Some( + std_.lifecycle_phases_deferred() + .expect("the standard declares the deferred phases") + .as_slice() + ) + ); + + for key in [ + "modes", + "platforms", + "lifecycle-phases-covered", + "lifecycle-phases-deferred", + ] { + assert!( + !block.list(key).unwrap().is_empty(), + "`{key}` is declared empty, which is no declaration at all" + ); + } + assert_eq!(block.missing_required(), Vec::<&'static str>::new()); + } } diff --git a/crates/launcher-common/tests/fixtures/metadata_block/README.adoc b/crates/launcher-common/tests/fixtures/metadata_block/README.adoc index 1002e73..6d1c07c 100644 --- a/crates/launcher-common/tests/fixtures/metadata_block/README.adoc +++ b/crates/launcher-common/tests/fixtures/metadata_block/README.adoc @@ -45,6 +45,19 @@ emits today. They remain here on purpose: this file is a record of what the old emitter wrote, and a scanner finding against it is a finding about history, not about the tool. Only the currency-locked artefact is linted in CI. +It also carries a fifth defect, and this one is fixed nowhere: it is missing +four of the standard's required metadata fields — `modes`, `platforms`, +`lifecycle-phases-covered` and `lifecycle-phases-deferred`. The emitter of +2026-09-22 never wrote them and the guard of the day never asked for them, so +this artefact was accepted as complete while being incomplete. That +contradiction is the defect #41 was filed about, and it is asserted rather +than smoothed over: `missing_required()` is pinned to exactly those four names +in both +`metadata_block::tests::the_committed_legacy_fixture_still_parses` and +`round_trip::a_launcher_minted_before_phase_two_still_reads`. What the file +still proves is the compat promise — it parses, and every value it carries +reads back unchanged. + == Re-minting the currency-locked artefact Never hand-edit it. Regenerate it, so the bytes come from the emitter: diff --git a/crates/launcher-common/tests/fixtures/metadata_block/minted-2026-09-23_stapeln-launcher-deed.sh b/crates/launcher-common/tests/fixtures/metadata_block/minted-2026-09-23_stapeln-launcher-deed.sh index 8d94a8c..b2ccff6 100644 --- a/crates/launcher-common/tests/fixtures/metadata_block/minted-2026-09-23_stapeln-launcher-deed.sh +++ b/crates/launcher-common/tests/fixtures/metadata_block/minted-2026-09-23_stapeln-launcher-deed.sh @@ -15,7 +15,11 @@ # (compliance :standard-version "0.4.0" # :standards ("launcher-standard.adoc" # "LM-LA-LIFECYCLE-STANDARD.adoc" -# "cross-platform-system-integration-modes"))) +# "cross-platform-system-integration-modes")) +# (modes :accepted ("--start" "--stop" "--status" "--browser" "--web" "--auto" "--integ" "--disinteg" "--help")) +# (platforms :supported ("linux" "macos" "windows")) +# (lifecycle-phases :covered ("start" "stop" "status" "integ" "disinteg") +# :deferred ("install" "uninstall" "update" "backup" "restore" "migrate"))) # @launcher-deed end # # ============================================================================ @@ -52,8 +56,23 @@ CONFIG_FILE="" URL="http://localhost:4010" WAIT_SECONDS="15" -PID_FILE="/tmp/stapeln-server.pid" -LOG_FILE="/tmp/stapeln-server.log" +PID_FILE="${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}}/stapeln-server.pid" +LOG_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/stapeln-server.log" + +# Both defaults live under a per-user XDG directory. Create them 0700 before +# the first write: a predictable path inside a world-writable directory (the +# old /tmp default) let any local user pre-create or symlink the pid file and +# steer what this script later killed or removed (Hypatia 82/83, #48). +# `mkdir -p -m` cannot do the job: with -p the mode applies only to the +# deepest directory created, so the chmod is stated separately and applies +# whether or not the directory was just made. +ensure_state_dirs() { + local pid_dir log_dir + pid_dir="$(dirname "$PID_FILE")" + log_dir="$(dirname "$LOG_FILE")" + mkdir -p "$pid_dir" "$log_dir" + chmod 0700 "$pid_dir" "$log_dir" +} START_COMMAND="" @@ -169,6 +188,7 @@ wait_for_url() { } start_server() { + ensure_state_dirs clear_stale_pid if is_running; then log "Already running (PID $(cat "$PID_FILE"))" diff --git a/crates/launcher-common/tests/round_trip.rs b/crates/launcher-common/tests/round_trip.rs index 9090232..a2b1dee 100644 --- a/crates/launcher-common/tests/round_trip.rs +++ b/crates/launcher-common/tests/round_trip.rs @@ -87,20 +87,37 @@ fn phase_two_mint_emits_the_deed_markers_and_not_the_legacy_ones() { fn as_legacy_script(block: &metadata_block::MetadataBlock) -> String { let get = |k: &str| block.scalar(k).unwrap_or_default().to_string(); - let standards = block - .lists - .iter() - .find(|(k, _)| k == "standards-compliance") - .map(|(_, v)| v.clone()) - .unwrap_or_default(); - let standards_rendered = standards - .iter() - .map(|s| format!("# \"{s}\"\n")) - .collect::(); - - // Fail loudly if the emitter grows a scalar the legacy dialect has no slot + let list_of = |key: &str| { + block + .lists + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.clone()) + .unwrap_or_default() + }; + let render_list = |values: &Vec| -> String { + values + .iter() + .map(|s| format!("# \"{s}\"\n")) + .collect::() + }; + + let standards = list_of("standards-compliance"); + let standards_rendered = render_list(&standards); + let modes_rendered = render_list(&list_of("modes")); + let platforms_rendered = render_list(&list_of("platforms")); + let covered_rendered = render_list(&list_of("lifecycle-phases-covered")); + let deferred_rendered = render_list(&list_of("lifecycle-phases-deferred")); + + // Fail loudly if the emitter grows a key the legacy dialect has no slot // for: silently dropping it is exactly the regression this whole change // exists to prevent. + // + // The four list slots below are the ones #41 taught the emitter to fill. + // They are here rather than left out because a legacy launcher is allowed + // to carry them too — the retired dialect's `key = [ … ]` syntax is + // generic, so the two dialects stay value-for-value equal now that the + // deed block declares them. for (k, _) in &block.scalars { match k.as_str() { "id" @@ -118,6 +135,19 @@ fn as_legacy_script(block: &metadata_block::MetadataBlock) -> String { ), } } + for (k, _) in &block.lists { + match k.as_str() { + "standards-compliance" + | "modes" + | "platforms" + | "lifecycle-phases-covered" + | "lifecycle-phases-deferred" => {} + other => panic!( + "the emitter emits list `{other}`, which the legacy dialect \ + cannot express — the compat reader would drop it" + ), + } + } format!( "#!/usr/bin/env bash\n\ @@ -135,6 +165,18 @@ fn as_legacy_script(block: &metadata_block::MetadataBlock) -> String { # ]\n\ # standard-spec-version = \"{spec}\"\n\ # generator = \"{generator}\"\n\ + # modes = [\n\ + {modes_rendered}\ + # ]\n\ + # platforms = [\n\ + {platforms_rendered}\ + # ]\n\ + # lifecycle-phases-covered = [\n\ + {covered_rendered}\ + # ]\n\ + # lifecycle-phases-deferred = [\n\ + {deferred_rendered}\ + # ]\n\ # )\n\ # @a2ml-metadata end\n\ \n\ @@ -189,6 +231,79 @@ fn both_dialects_agree_on_what_todays_emitter_produces() { assert_eq!(deed.missing_required(), Vec::::new()); } +/// The four declarations #41 taught the emitter to make survive the full +/// round trip: emitted by `mint`, read back by the deed reader, carried in the +/// **retired** dialect, and read back again with the same values. +/// +/// `both_dialects_agree_on_what_todays_emitter_produces` above already proves +/// the two dialects flatten alike, so this test would be implied by it if the +/// new fields ever reached that far. It is here because they very nearly did +/// not: the helper that renders the legacy side, `as_legacy_script`, has an +/// explicit list of the keys it knows how to express and panics on any other, +/// so the instant the emitter grew four new lists the compat claim stopped +/// being exercised rather than failing. This test says which values must +/// survive, so the four cannot be dropped without it going red. +#[test] +fn the_four_new_declarations_survive_mint_parse_legacy_parse() { + let minted = mint(); + let deed = metadata_block::parse_from_text(&minted) + .expect("parses") + .expect("has a block"); + + // 1. They are emitted at all — the defect, stated positively. + for key in [ + "modes", + "platforms", + "lifecycle-phases-covered", + "lifecycle-phases-deferred", + ] { + assert!( + deed.list(key).is_some(), + "`mint` does not declare `{key}`, which the standard requires" + ); + assert!( + !deed.list(key).unwrap().is_empty(), + "`{key}` is declared as the empty list, which satisfies a presence \ + check while claiming nothing" + ); + } + + // 2. They take their values from the standard, not from the template. + let std_ = LauncherStandard::baked().expect("baked standard loads"); + assert_eq!( + deed.list("platforms"), + Some(std_.platforms().unwrap().as_slice()) + ); + assert_eq!( + deed.list("lifecycle-phases-covered"), + Some(std_.lifecycle_phases_covered().unwrap().as_slice()) + ); + assert_eq!( + deed.list("lifecycle-phases-deferred"), + Some(std_.lifecycle_phases_deferred().unwrap().as_slice()) + ); + + // 3. They come back unchanged through the retired dialect, which is the + // direction a launcher already on disk takes. + let legacy_script = as_legacy_script(&deed); + let legacy = metadata_block::parse_from_text(&legacy_script) + .expect("the generated legacy script parses") + .expect("the generated legacy script carries a block"); + + for key in [ + "modes", + "platforms", + "lifecycle-phases-covered", + "lifecycle-phases-deferred", + ] { + assert_eq!( + legacy.list(key), + deed.list(key), + "`{key}` did not survive the round trip through the legacy dialect" + ); + } +} + /// The deed leg of `realign`: a deed block is never rewritten in place, so a /// deed-carrying launcher is only ever regenerated. /// @@ -226,7 +341,28 @@ fn a_launcher_minted_before_phase_two_still_reads() { .expect("a pre-phase launcher carries a metadata block"); assert!(!block.is_deed(), "the fixture is the retired dialect"); - assert_eq!(block.missing_required(), Vec::::new()); + + // ⚠ Not `Vec::::new()` any more, and the reason is the defect #41 + // was filed about: the emitter of 2026-09-22 never emitted `modes`, + // `platforms` or the two lifecycle-phase lists, and the guard of the day + // did not ask for them either, so this artefact was accepted as complete + // while missing four of the standard's required fields. The four are + // named rather than skipped so that a future tightening has to confront + // them instead of inheriting the silence. + assert_eq!( + block.missing_required(), + vec![ + "modes", + "platforms", + "lifecycle-phases-covered", + "lifecycle-phases-deferred" + ], + "a pre-phase launcher is missing exactly the four declarations the \ + pre-phase emitter never emitted" + ); + // Everything it DOES carry is still complete: id, type, version, + // app-name, app-display, app-url, standards-compliance. + assert_eq!(block.present_required().len(), 7); // The same config this fixture was minted from, through today's emitter. let deed = metadata_block::parse_from_text(&mint()) @@ -236,7 +372,17 @@ fn a_launcher_minted_before_phase_two_still_reads() { block.scalars, deed.scalars, "a pre-phase launcher and a launcher minted today must carry the same values" ); - assert_eq!(block.lists, deed.lists); + // Lists are compared per key, not wholesale: today's mint legitimately + // carries four the 2026-09-22 emitter did not have. Comparing the whole + // vector would either fail (masking a real drift) or be trimmed until it + // stopped noticing changes to `standards-compliance`. + for (key, values) in &block.lists { + assert_eq!( + deed.list(key), + Some(values.as_slice()), + "today's emitter changed list `{key}`" + ); + } } /// The post-phase fixture: a launcher minted **by this change**, committed as diff --git a/standards/launcher-standard_praxis.deed b/standards/launcher-standard_praxis.deed index c751c4e..1b9de33 100644 --- a/standards/launcher-standard_praxis.deed +++ b/standards/launcher-standard_praxis.deed @@ -251,6 +251,35 @@ (tool :name panic-attack :style command :trigger on-start-failed :command "panic-attack assail {repo-dir}")) + ;; ---------------------------------------------------------------- platforms + ;; The platforms a compliant launcher handles. This clause EXISTS because + ;; (metadata-block :required-fields) demands a `platforms` field, and a + ;; required field with no declared domain is not a requirement -- any list + ;; of strings satisfies it. Declared here once, so every launcher names the + ;; same set rather than inventing one per file (#41). + ;; + ;; PROVISIONAL: these are the arms of the generated script's own platform + ;; switch today. Adding a platform means adding the arm AND this clause. + (platforms :supported ("linux" "macos" "windows")) + + ;; --------------------------------------------------------- lifecycle-phases + ;; The lifecycle a launcher covers, and the phases it defers to the + ;; provisioning layer. Same reason for existing as (platforms) above. + ;; + ;; `covered` names what the generated script itself performs; `deferred` + ;; names the phases it deliberately does not, so the block states the whole + ;; lifecycle rather than only the launcher's half of it. install/update/ + ;; backup/restore/migrate belong to nix, mise and `launch-scaffolder + ;; provision`, not to a launcher script. + ;; + ;; PROVISIONAL: the estate's lifecycle vocabulary lives in + ;; LM-LA-LIFECYCLE-STANDARD, which is not yet a deed. These names are the + ;; launcher's own phase names, and are recorded as a finding against that + ;; standard rather than invented per launcher (#41). + (lifecycle-phases + :covered ("start" "stop" "status" "integ" "disinteg") + :deferred ("install" "uninstall" "update" "backup" "restore" "migrate")) + ;; ------------------------------------------------------------ metadata-block ;; Every generated launcher must carry this metadata block in its header so ;; it can be re-parsed by `launch-scaffolder config` and `realign`. @@ -259,4 +288,37 @@ (metadata-block :required-fields ("id" "type" "version" "app-name" "app-display" "app-url" "standards-compliance" "modes" "platforms" - "lifecycle-phases-covered" "lifecycle-phases-deferred"))) + "lifecycle-phases-covered" "lifecycle-phases-deferred") + + ;; ---------------------------------------------------------------- encoding + ;; HOW the block is carried, not just WHAT it must say. + ;; + ;; Until this clause existed the delimiters and the field syntax lived + ;; only in launch-scaffolder's parser, so a launcher could satisfy every + ;; requirement above and still be invisible to the tool the standard names + ;; as its consumer -- `hyperpolymath/trigger`'s launcher carries all + ;; eleven fields in a `key: value` dialect with no markers at all, and no + ;; release of this tool has ever been able to read it (#41). + ;; + ;; Conformance and parseability are the same property from here on: the + ;; parser's own constants are asserted equal to these strings by + ;; `metadata_block::tests::the_parser_markers_are_the_ones_the_deed_declares`. + (encoding + ;; The marker pair a launcher carries, each as its own `#` comment line. + :marker-begin "# @launcher-deed begin" + :marker-end "# @launcher-deed end" + ;; The retired pair. Still written by every launcher minted before + ;; 2026-09-23, so a reader MUST accept it for as long as those + ;; launchers exist; `mint` no longer emits it. + :retired-marker-begin "# @a2ml-metadata begin" + :retired-marker-end "# @a2ml-metadata end" + ;; One `#` (and one following space) is stripped from every line before + ;; the text is handed to the DEED grammar: `#` is not a legal DEED + ;; character and `;;` is the grammar's own comment marker. + :comment-prefix "#" + ;; The block's document head. Must match the deed-filename dispatch + ;; (see 1-formats/deed/spec/DEED-GRAMMAR-SPEC.adoc). + :head "praxis-deed" + ;; Fields are `(clause :keyword value ...)` s-expressions -- NOT + ;; `key = value`, which is not DEED. Lists are `( ... )`. + :syntax "s-expression"))) diff --git a/templates/launcher.sh.tera b/templates/launcher.sh.tera index 65e17ed..ba5d5b1 100644 --- a/templates/launcher.sh.tera +++ b/templates/launcher.sh.tera @@ -39,7 +39,22 @@ # (compliance :standard-version "{{ spec_version | deedstr }}" # :standards ("launcher-standard.adoc" # "LM-LA-LIFECYCLE-STANDARD.adoc" -# "cross-platform-system-integration-modes"))) +# "cross-platform-system-integration-modes")) +{#- + The four declarations below were required by the standard from the start + and emitted by nothing: (metadata-block :required-fields) has always named + them, and no launcher minted before 2026-09-25 carries them (#41). The + platforms and the lifecycle phases come from the standard's own (platforms) + and (lifecycle-phases) clauses, so the vocabulary is named once rather than + invented here; the modes are the arms of this script's own main switch, + pinned to it by a test in template.rs. + NOTE: this tag closes WITHOUT whitespace-stripping on its right. Stripping + there joins the first clause below onto the compliance clause above it. +#} +# (modes :accepted ({{ modes | safe }})) +# (platforms :supported ({{ platforms | safe }})) +# (lifecycle-phases :covered ({{ lifecycle_phases_covered | safe }}) +# :deferred ({{ lifecycle_phases_deferred | safe }}))) # @launcher-deed end # # ============================================================================ @@ -95,6 +110,21 @@ URL="" PID_FILE="{{ pid_file }}" LOG_FILE="{{ log_file }}" +# Both defaults live under a per-user XDG directory. Create them 0700 before +# the first write: a predictable path inside a world-writable directory (the +# old /tmp default) let any local user pre-create or symlink the pid file and +# steer what this script later killed or removed (Hypatia 82/83, #48). +# `mkdir -p -m` cannot do the job: with -p the mode applies only to the +# deepest directory created, so the chmod is stated separately and applies +# whether or not the directory was just made. +ensure_state_dirs() { + local pid_dir log_dir + pid_dir="$(dirname "$PID_FILE")" + log_dir="$(dirname "$LOG_FILE")" + mkdir -p "$pid_dir" "$log_dir" + chmod 0700 "$pid_dir" "$log_dir" +} + {% if explicit_command | length > 0 -%} # Explicit argv from [runtime].command START_COMMAND=({% for arg in explicit_command %}{{ arg | safe }} {% endfor %}) @@ -229,6 +259,7 @@ wait_for_url() { {%- endif %} start_server() { + ensure_state_dirs clear_stale_pid {% if runtime_kind == "remote" -%} log "$APP_DISPLAY is a remote web app — nothing to start locally."