Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 96 additions & 1 deletion crates/launcher-common/src/standard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
);
Expand All @@ -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}");
}
}
30 changes: 17 additions & 13 deletions crates/launcher-common/src/template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ fn deed_list(values: &[String]) -> Result<String> {

pub fn render(
config: &LauncherConfig,
_standard: &LauncherStandard,
standard: &LauncherStandard,
config_path: Option<&Path>,
) -> Result<String> {
config.validate()?;
Expand Down Expand Up @@ -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,
),
};
Expand Down Expand Up @@ -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).
Expand All @@ -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",
)?)?,
Expand Down
20 changes: 10 additions & 10 deletions docs/ruleset-audit-2026-04-10/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
----

Expand All @@ -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
----

Expand Down
11 changes: 8 additions & 3 deletions docs/ruleset-audit-2026-04-10/audit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
4 changes: 2 additions & 2 deletions docs/ruleset-audit-2026-04-10/wave1-apply.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/ruleset-audit-2026-04-10/wave2-apply.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 11 additions & 5 deletions standards/launcher-standard_praxis.deed
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading