diff --git a/crates/launcher-common/src/standard.rs b/crates/launcher-common/src/standard.rs index fdbb4c0..429fb33 100644 --- a/crates/launcher-common/src/standard.rs +++ b/crates/launcher-common/src/standard.rs @@ -266,6 +266,50 @@ impl LauncherStandard { }; str_list_of(value, &format!("(lifecycle-phases :{keyword} …)")) } + + /// The default pid-file location, from `(runtime :pid-file-pattern …)`, + /// as a shell expression containing `{app-name}`. + /// + /// A missing key is an error, never a fallback: the defect this replaces + /// (#48) was a generator that "followed the standard" in a comment while + /// hard-coding `/tmp` in code. + pub fn pid_file_pattern(&self) -> Result<&str> { + self.runtime_pattern("pid-file-pattern") + } + + /// The default log-file location, from `(runtime :log-file-pattern …)`. + pub fn log_file_pattern(&self) -> Result<&str> { + self.runtime_pattern("log-file-pattern") + } + + fn runtime_pattern(&self, key: &str) -> Result<&str> { + let Some(pattern) = self + .doc + .clause("runtime") + .and_then(|runtime| runtime.str_field(key)) + else { + anyhow::bail!( + "the launcher standard's (runtime) clause is missing :{key}. Refusing to \ + fall back to a built-in default: the standard is the only source of it" + ); + }; + if !pattern.contains("{app-name}") { + anyhow::bail!( + "(runtime :{key}) is `{pattern}`, which has no {{app-name}} placeholder, so \ + every launcher on a host would share one file" + ); + } + // `mint` resolves the standard from an on-disk ladder, so a stale copy + // can reach here; refuse the world-writable fallback rather than mint it. + if pattern.contains("/tmp") || pattern.contains("TMPDIR") { + anyhow::bail!( + "(runtime :{key}) is `{pattern}`, which can resolve into world-writable \ + temp space with a name predictable from the app (CWE-377, #48). This \ + standard predates the XDG-only ladder; update it or pass --standard" + ); + } + Ok(pattern) + } } /// Every element of a list value, as owned strings, or an error naming the @@ -636,7 +680,7 @@ mod tests { use sha2::{Digest, Sha256}; let got = format!("{:x}", Sha256::digest(BAKED_STANDARD.as_bytes())); assert_eq!( - got, "8f55bcbbd06a7a8dd78ebff532af32009b7fc6cc7f07064fdef7d0402ad5cefe", + got, "29c12fbdb34aa1915d4efa185debe4362af4a2034c66c87e434dca39751bd135", "standards/launcher-standard_praxis.deed changed; re-vendor deliberately \ and update this pin in the same commit" ); @@ -660,4 +704,55 @@ mod tests { assert!(r.field("priority").and_then(Value::as_int).is_some()); } } + + const BAKED_PID_PATTERN: &str = "${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}}/launch-scaffolder/{app-name}/server.pid"; + + /// The baked standard's runtime patterns, pinned as literals: a pattern + /// computed from the same source it is compared against could not fail. + #[test] + fn runtime_patterns_are_read_from_the_standard() { + let s = LauncherStandard::baked().unwrap(); + assert_eq!(s.pid_file_pattern().unwrap(), BAKED_PID_PATTERN); + assert_eq!( + s.log_file_pattern().unwrap(), + "${XDG_STATE_HOME:-$HOME/.local/state}/launch-scaffolder/{app-name}/server.log" + ); + } + + fn baked_with_pid_pattern(replacement: &str) -> LauncherStandard { + let original = format!(":pid-file-pattern \"{BAKED_PID_PATTERN}\""); + assert!( + BAKED_STANDARD.contains(&original), + "control: the edit must land" + ); + LauncherStandard::parse(&BAKED_STANDARD.replace(&original, replacement)) + .expect("the mutated standard still parses") + } + + #[test] + fn a_missing_runtime_pattern_is_an_error_not_a_fallback() { + let s = baked_with_pid_pattern(""); + let err = s.pid_file_pattern().unwrap_err().to_string(); + assert!(err.contains(":pid-file-pattern"), "{err}"); + } + + #[test] + fn a_pattern_without_app_name_is_refused() { + let s = baked_with_pid_pattern( + ":pid-file-pattern \"${XDG_RUNTIME_DIR:-$HOME/.local/state}/server.pid\"", + ); + let err = s.pid_file_pattern().unwrap_err().to_string(); + assert!(err.contains("{app-name}"), "{err}"); + } + + /// The pre-2026-09-30 standard's own ladder: a stale on-disk copy reached + /// through the resolution ladder must not mint a `/tmp` fallback. + #[test] + fn the_retired_tmpdir_ladder_is_refused() { + let s = baked_with_pid_pattern( + ":pid-file-pattern \"${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/{app-name}-server.pid\"", + ); + let err = s.pid_file_pattern().unwrap_err().to_string(); + assert!(err.contains("CWE-377"), "{err}"); + } } diff --git a/crates/launcher-common/src/template.rs b/crates/launcher-common/src/template.rs index 63b59a4..1ae1dcb 100644 --- a/crates/launcher-common/src/template.rs +++ b/crates/launcher-common/src/template.rs @@ -67,7 +67,7 @@ fn deed_list(values: &[String]) -> Result { pub fn render( config: &LauncherConfig, - _standard: &LauncherStandard, + standard: &LauncherStandard, config_path: Option<&Path>, ) -> Result { config.validate()?; @@ -192,23 +192,27 @@ pub fn render( // 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. + // + // The patterns themselves come from the standard's `(runtime + // :pid-file-pattern / :log-file-pattern)`, not from this file: the + // original defect sat under a comment claiming to follow the standard while + // the code never read it, and the two drifted. `LauncherStandard` refuses a + // pattern that is missing, lacks `{app-name}`, or names `/tmp`/`TMPDIR`. let (pid_file, pid_file_is_default) = match &config.runtime.pid_file { Some(path) => (path.clone(), false), None => ( - format!( - "${{XDG_RUNTIME_DIR:-${{XDG_STATE_HOME:-$HOME/.local/state}}}}/launch-scaffolder/{}/server.pid", - config.project.name - ), + standard + .pid_file_pattern()? + .replace("{app-name}", &config.project.name), true, ), }; let (log_file, log_file_is_default) = match &config.runtime.log_file { Some(path) => (path.clone(), false), None => ( - format!( - "${{XDG_STATE_HOME:-$HOME/.local/state}}/launch-scaffolder/{}/server.log", - config.project.name - ), + standard + .log_file_pattern()? + .replace("{app-name}", &config.project.name), true, ), }; @@ -247,7 +251,7 @@ pub fn render( ctx.insert("icon_source_shell", &shell_path_quote(&icon_source)); // --- metadata ----------------------------------------------------- - ctx.insert("spec_version", &_standard.spec_version); + 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). @@ -269,21 +273,21 @@ pub fn render( ); ctx.insert( "platforms", - &deed_list(&_standard.platforms().context( + &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( + &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( + &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", )?)?, diff --git a/docs/ruleset-audit-2026-04-10/README.adoc b/docs/ruleset-audit-2026-04-10/README.adoc index 99e9e49..b1af635 100644 --- a/docs/ruleset-audit-2026-04-10/README.adoc +++ b/docs/ruleset-audit-2026-04-10/README.adoc @@ -70,10 +70,10 @@ jq -r .state report.jsonl | sort | uniq -c [source,bash] ---- # Pick your wave — DRIFT this time: -jq -r 'select(.state=="DRIFT") | .repo' report.jsonl > /tmp/wave2-repos.txt +jq -r 'select(.state=="DRIFT") | .repo' report.jsonl > wave2-repos.txt # Exclude forks: -comm -23 <(sort /tmp/wave2-repos.txt) forks.txt > /tmp/wave2-apply.txt +comm -23 <(sort wave2-repos.txt) forks.txt > wave2-apply.txt # For Wave 2 the existing (broken) ruleset must be deleted first, # because POST will fail with "Name must be unique" on every repo. @@ -83,7 +83,7 @@ comm -23 <(sort /tmp/wave2-repos.txt) forks.txt > /tmp/wave2-apply.txt # Dry-run with alternate owner: OWNER=The-Metadatastician \ -REPOS_FILE=/tmp/wave2-apply.txt \ +REPOS_FILE=wave2-apply.txt \ bash wave2-apply.sh --dry-run ---- @@ -92,20 +92,20 @@ bash wave2-apply.sh --dry-run [source,bash] ---- # Build Wave 1 repo list from current report: -jq -r 'select(.state=="MISSING") | .repo' report.jsonl > /tmp/wave1-repos.txt +jq -r 'select(.state=="MISSING") | .repo' report.jsonl > wave1-repos.txt # Dry-run / plan generation: OWNER=The-Metadatastician \ -REPOS_FILE=/tmp/wave1-repos.txt \ -PLAN_FILE=/tmp/ruleset-audit/wave1-plan-The-Metadatastician.jsonl \ -RESULTS_FILE=/tmp/ruleset-audit/wave1-results-The-Metadatastician.tsv \ +REPOS_FILE=wave1-repos.txt \ +PLAN_FILE=wave1-plan-The-Metadatastician.jsonl \ +RESULTS_FILE=wave1-results-The-Metadatastician.tsv \ bash wave1-apply.sh --dry-run # Apply: OWNER=The-Metadatastician \ -REPOS_FILE=/tmp/wave1-repos.txt \ -PLAN_FILE=/tmp/ruleset-audit/wave1-plan-The-Metadatastician.jsonl \ -RESULTS_FILE=/tmp/ruleset-audit/wave1-results-The-Metadatastician.tsv \ +REPOS_FILE=wave1-repos.txt \ +PLAN_FILE=wave1-plan-The-Metadatastician.jsonl \ +RESULTS_FILE=wave1-results-The-Metadatastician.tsv \ bash wave1-apply.sh ---- diff --git a/docs/ruleset-audit-2026-04-10/audit.sh b/docs/ruleset-audit-2026-04-10/audit.sh index 949633a..cf22f2c 100755 --- a/docs/ruleset-audit-2026-04-10/audit.sh +++ b/docs/ruleset-audit-2026-04-10/audit.sh @@ -30,12 +30,17 @@ # bypass_actors = [{actor_type: RepositoryRole, actor_id: 5, bypass_mode: always}] # (actor_id=5 is the built-in Admin role) # -# Writes one JSON-per-line record to /tmp/ruleset-audit/report.jsonl. +# Writes one JSON-per-line record to report.jsonl beside this script. +# +# Inputs and outputs default to this directory, not /tmp: they are a hand-off +# between audit.sh and the wave scripts, so they must be re-findable, and a +# fixed name in world-writable /tmp can be pre-created by another user. set -euo pipefail -REPOS_FILE="${REPOS_FILE:-/tmp/ruleset-audit/repos.tsv}" -REPORT_FILE="${REPORT_FILE:-/tmp/ruleset-audit/report.jsonl}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPOS_FILE="${REPOS_FILE:-$SCRIPT_DIR/repos.tsv}" +REPORT_FILE="${REPORT_FILE:-$SCRIPT_DIR/report.jsonl}" OWNER="${OWNER:-hyperpolymath}" : > "$REPORT_FILE" diff --git a/docs/ruleset-audit-2026-04-10/wave1-apply.sh b/docs/ruleset-audit-2026-04-10/wave1-apply.sh index 3c9c94b..ed019dd 100755 --- a/docs/ruleset-audit-2026-04-10/wave1-apply.sh +++ b/docs/ruleset-audit-2026-04-10/wave1-apply.sh @@ -19,8 +19,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" OWNER="${OWNER:-hyperpolymath}" -REPOS_FILE="${REPOS_FILE:-/tmp/ruleset-audit/wave1-repos.txt}" -PLAN_FILE="${PLAN_FILE:-/tmp/ruleset-audit/wave1-plan.jsonl}" +REPOS_FILE="${REPOS_FILE:-$SCRIPT_DIR/wave1-repos.txt}" +PLAN_FILE="${PLAN_FILE:-$SCRIPT_DIR/wave1-plan.jsonl}" RESULTS_FILE="${RESULTS_FILE:-$SCRIPT_DIR/wave1-results.tsv}" DRY_RUN=false diff --git a/docs/ruleset-audit-2026-04-10/wave2-apply.sh b/docs/ruleset-audit-2026-04-10/wave2-apply.sh index 5de5b3b..f42ab1a 100644 --- a/docs/ruleset-audit-2026-04-10/wave2-apply.sh +++ b/docs/ruleset-audit-2026-04-10/wave2-apply.sh @@ -26,7 +26,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" OWNER="${OWNER:-hyperpolymath}" -REPOS_FILE="${REPOS_FILE:-/tmp/ruleset-audit/wave2-repos.txt}" +REPOS_FILE="${REPOS_FILE:-$SCRIPT_DIR/wave2-repos.txt}" RESULTS_FILE="${RESULTS_FILE:-$SCRIPT_DIR/wave2-results.tsv}" DRY_RUN=false diff --git a/standards/launcher-standard_praxis.deed b/standards/launcher-standard_praxis.deed index 1b9de33..68d7be5 100644 --- a/standards/launcher-standard_praxis.deed +++ b/standards/launcher-standard_praxis.deed @@ -121,12 +121,18 @@ ;; ------------------------------------------------------------------- runtime (runtime :background nohup - ;; PID files live in the user's runtime-state dir -- wiped on logout, - ;; user-scoped (mode 0700 per XDG), no symlink-attack target. - :pid-file-pattern "${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/{app-name}-server.pid" + ;; PID files live in the user's runtime dir -- wiped on logout, user-scoped + ;; (mode 0700 per XDG), no symlink-attack target. The fallback is + ;; XDG_STATE_HOME, NEVER TMPDIR or /tmp: a world-writable directory with a + ;; name predictable from {app-name} lets another user pre-create the file + ;; and choose which PID `stop` kills (CWE-377). mktemp is not an option + ;; either -- a pid file must be re-findable by the next invocation. + ;; The launch-scaffolder/{app-name} subdir is created 0700 by the launcher. + :pid-file-pattern "${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}}/launch-scaffolder/{app-name}/server.pid" ;; Logs go to XDG_STATE_HOME. Per-user, survives reboot, not - ;; world-writable; the {app-name} subdir isolates each launcher's logs. - :log-file-pattern "${XDG_STATE_HOME:-$HOME/.local/state}/{app-name}/server.log" + ;; world-writable; the launch-scaffolder/{app-name} subdir isolates each + ;; launcher's logs and keeps them beside its pid fallback. + :log-file-pattern "${XDG_STATE_HOME:-$HOME/.local/state}/launch-scaffolder/{app-name}/server.log" ;; URL-readiness polling after start. All three timing values are env-var ;; overridable so operators can tune without re-minting the launcher. :wait-for-url-timeout-seconds 15