fix(startup): guard collector output cleanup
- commit
- 862d933754ec958204ee2da116d65fa6956c534a
- author
- brice · · 2026-07-14 09:00 UTC
- committer
- brice · · 2026-07-14 09:35 UTC
- parent
- e4b5a97
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +136 -6
M docs/first-presentation.md +4 -1
| @@ -25,10 +25,13 @@ Build the candidate, then run: | |||
| 25 | 25 | ||
| 26 | 26 | ```bash | |
| 27 | 27 | DUMBER_FIRST_PRESENTATION_BIN="$PWD/dist/dumber" \ | |
| 28 | + | DUMBER_FIRST_PRESENTATION_OUTPUT="$PWD/phase1/first-presentation-$(date +%s)" \ | |
| 28 | 29 | scripts/collect_first_presentation.sh | |
| 29 | 30 | ``` | |
| 30 | 31 | ||
| 31 | - | The script requires the current display and the CEF 147 runtime at | |
| 32 | + | The output directory must be a new, absolute directory below an existing | |
| 33 | + | non-symlink parent. The collector refuses to clear or reuse a caller-supplied | |
| 34 | + | path; review and publish a fresh collection explicitly. The script requires the current display and the CEF 147 runtime at | |
| 32 | 35 | `~/.local/share/cef-147-runtime` (override with `DUMBER_CEF_DIR`). It performs | |
| 33 | 36 | exactly five bounded launches with fresh XDG homes and CEF root cache, fixes the | |
| 34 | 37 | DMABUF/Vulkan environment, and writes `metadata.json`, `run-01.json` through | |
| @@ -25,10 +25,13 @@ Build the candidate, then run: | |||||
| 25 | 25 | ||||
| 26 | ```bash | 26 | ```bash | ||
| 27 | DUMBER_FIRST_PRESENTATION_BIN="$PWD/dist/dumber" \ | 27 | DUMBER_FIRST_PRESENTATION_BIN="$PWD/dist/dumber" \ | ||
| 28 | + | DUMBER_FIRST_PRESENTATION_OUTPUT="$PWD/phase1/first-presentation-$(date +%s)" \ | |||
| 28 | scripts/collect_first_presentation.sh | 29 | scripts/collect_first_presentation.sh | ||
| 29 | ``` | 30 | ``` | ||
| 30 | 31 | ||||
| 31 | - | The script requires the current display and the CEF 147 runtime at | 32 | + | The output directory must be a new, absolute directory below an existing |
| 33 | + | non-symlink parent. The collector refuses to clear or reuse a caller-supplied | |||
| 34 | + | path; review and publish a fresh collection explicitly. The script requires the current display and the CEF 147 runtime at | |||
| 32 | `~/.local/share/cef-147-runtime` (override with `DUMBER_CEF_DIR`). It performs | 35 | `~/.local/share/cef-147-runtime` (override with `DUMBER_CEF_DIR`). It performs | ||
| 33 | exactly five bounded launches with fresh XDG homes and CEF root cache, fixes the | 36 | exactly five bounded launches with fresh XDG homes and CEF root cache, fixes the | ||
| 34 | DMABUF/Vulkan environment, and writes `metadata.json`, `run-01.json` through | 37 | DMABUF/Vulkan environment, and writes `metadata.json`, `run-01.json` through | ||
M internal/logging/first_presentation_collector_test.go +86 -0
| @@ -20,6 +20,92 @@ const validFirstPresentationLog = `{"message":"startup_trace: milestone","milest | |||
| 20 | 20 | {"message":"startup_trace: milestone","milestone":"first_gtk_presentation","t_ms":7,"delta_ms":1} | |
| 21 | 21 | {"message":"startup_trace: first presentation","backend":"gdk-dmabuf","incomplete_reason":"","total_ms":7,"host":"alice"}` | |
| 22 | 22 | ||
| 23 | + | func TestFirstPresentationCollectorNeverRecursivelyDeletesCallerOutput(t *testing.T) { | |
| 24 | + | repoRoot, err := filepath.Abs(filepath.Join("..", "..")) | |
| 25 | + | require.NoError(t, err) | |
| 26 | + | script, err := os.ReadFile(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh")) | |
| 27 | + | require.NoError(t, err) | |
| 28 | + | require.NotContains(t, string(script), "rm -rf \"$output\"") | |
| 29 | + | require.NotContains(t, string(script), "rm -rf -- \"$output\"") | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestFirstPresentationCollectorRejectsUnsafeOutputPaths(t *testing.T) { | |
| 33 | + | repoRoot, err := filepath.Abs(filepath.Join("..", "..")) | |
| 34 | + | require.NoError(t, err) | |
| 35 | + | temp := t.TempDir() | |
| 36 | + | runtime := filepath.Join(temp, "cef-147-runtime") | |
| 37 | + | require.NoError(t, os.Mkdir(runtime, 0o755)) | |
| 38 | + | binary := filepath.Join(temp, "dumber") | |
| 39 | + | require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755)) | |
| 40 | + | home := filepath.Join(temp, "home") | |
| 41 | + | require.NoError(t, os.Mkdir(home, 0o755)) | |
| 42 | + | ||
| 43 | + | unrelated := filepath.Join(temp, "unrelated") | |
| 44 | + | require.NoError(t, os.Mkdir(unrelated, 0o755)) | |
| 45 | + | unrelatedSentinel := filepath.Join(unrelated, "keep") | |
| 46 | + | require.NoError(t, os.WriteFile(unrelatedSentinel, []byte("do not delete"), 0o600)) | |
| 47 | + | ||
| 48 | + | safeParent := filepath.Join(temp, "safe") | |
| 49 | + | require.NoError(t, os.Mkdir(safeParent, 0o755)) | |
| 50 | + | parentSentinel := filepath.Join(safeParent, "keep") | |
| 51 | + | require.NoError(t, os.WriteFile(parentSentinel, []byte("do not delete"), 0o600)) | |
| 52 | + | ||
| 53 | + | external := filepath.Join(temp, "external") | |
| 54 | + | require.NoError(t, os.Mkdir(external, 0o755)) | |
| 55 | + | externalSentinel := filepath.Join(external, "keep") | |
| 56 | + | require.NoError(t, os.WriteFile(externalSentinel, []byte("do not delete"), 0o600)) | |
| 57 | + | link := filepath.Join(temp, "link") | |
| 58 | + | require.NoError(t, os.Symlink(external, link)) | |
| 59 | + | ||
| 60 | + | for _, test := range []struct { | |
| 61 | + | name string | |
| 62 | + | output string | |
| 63 | + | verify func(t *testing.T) | |
| 64 | + | }{ | |
| 65 | + | {name: "empty", output: ""}, | |
| 66 | + | {name: "root", output: "/"}, | |
| 67 | + | {name: "home", output: home}, | |
| 68 | + | {name: "existing unrelated directory", output: unrelated, verify: func(t *testing.T) { | |
| 69 | + | requireFileContents(t, unrelatedSentinel, "do not delete") | |
| 70 | + | }}, | |
| 71 | + | {name: "parent traversal", output: safeParent + "/../escaped", verify: func(t *testing.T) { | |
| 72 | + | requireFileContents(t, parentSentinel, "do not delete") | |
| 73 | + | require.NoFileExists(t, filepath.Join(temp, "escaped")) | |
| 74 | + | }}, | |
| 75 | + | {name: "symlink escape", output: filepath.Join(link, "escaped"), verify: func(t *testing.T) { | |
| 76 | + | requireFileContents(t, externalSentinel, "do not delete") | |
| 77 | + | require.NoFileExists(t, filepath.Join(external, "escaped")) | |
| 78 | + | }}, | |
| 79 | + | } { | |
| 80 | + | t.Run(test.name, func(t *testing.T) { | |
| 81 | + | cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh")) | |
| 82 | + | // An isolated directory prevents a regression from reaching the tracked | |
| 83 | + | // default artifact path while this test proves output validation is first. | |
| 84 | + | cmd.Dir = temp | |
| 85 | + | cmd.Env = append(os.Environ(), | |
| 86 | + | "HOME="+home, | |
| 87 | + | "DISPLAY=:test", | |
| 88 | + | "DUMBER_CEF_DIR="+runtime, | |
| 89 | + | "DUMBER_FIRST_PRESENTATION_BIN="+binary, | |
| 90 | + | "DUMBER_FIRST_PRESENTATION_OUTPUT="+test.output, | |
| 91 | + | ) | |
| 92 | + | result, err := cmd.CombinedOutput() | |
| 93 | + | require.Errorf(t, err, "collector accepted unsafe output %q: %s", test.output, result) | |
| 94 | + | require.Contains(t, string(result), "unsafe output path") | |
| 95 | + | if test.verify != nil { | |
| 96 | + | test.verify(t) | |
| 97 | + | } | |
| 98 | + | }) | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func requireFileContents(t *testing.T, path, want string) { | |
| 103 | + | t.Helper() | |
| 104 | + | contents, err := os.ReadFile(path) | |
| 105 | + | require.NoError(t, err) | |
| 106 | + | require.Equal(t, want, string(contents)) | |
| 107 | + | } | |
| 108 | + | ||
| 23 | 109 | func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) { | |
| 24 | 110 | repoRoot, err := filepath.Abs(filepath.Join("..", "..")) | |
| 25 | 111 | require.NoError(t, err) | |
| @@ -20,6 +20,92 @@ const validFirstPresentationLog = `{"message":"startup_trace: milestone","milest | |||||
| 20 | {"message":"startup_trace: milestone","milestone":"first_gtk_presentation","t_ms":7,"delta_ms":1} | 20 | {"message":"startup_trace: milestone","milestone":"first_gtk_presentation","t_ms":7,"delta_ms":1} | ||
| 21 | {"message":"startup_trace: first presentation","backend":"gdk-dmabuf","incomplete_reason":"","total_ms":7,"host":"alice"}` | 21 | {"message":"startup_trace: first presentation","backend":"gdk-dmabuf","incomplete_reason":"","total_ms":7,"host":"alice"}` | ||
| 22 | 22 | ||||
| 23 | + | func TestFirstPresentationCollectorNeverRecursivelyDeletesCallerOutput(t *testing.T) { | |||
| 24 | + | repoRoot, err := filepath.Abs(filepath.Join("..", "..")) | |||
| 25 | + | require.NoError(t, err) | |||
| 26 | + | script, err := os.ReadFile(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh")) | |||
| 27 | + | require.NoError(t, err) | |||
| 28 | + | require.NotContains(t, string(script), "rm -rf \"$output\"") | |||
| 29 | + | require.NotContains(t, string(script), "rm -rf -- \"$output\"") | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func TestFirstPresentationCollectorRejectsUnsafeOutputPaths(t *testing.T) { | |||
| 33 | + | repoRoot, err := filepath.Abs(filepath.Join("..", "..")) | |||
| 34 | + | require.NoError(t, err) | |||
| 35 | + | temp := t.TempDir() | |||
| 36 | + | runtime := filepath.Join(temp, "cef-147-runtime") | |||
| 37 | + | require.NoError(t, os.Mkdir(runtime, 0o755)) | |||
| 38 | + | binary := filepath.Join(temp, "dumber") | |||
| 39 | + | require.NoError(t, os.WriteFile(binary, collectorTestBinary(validFirstPresentationLog), 0o755)) | |||
| 40 | + | home := filepath.Join(temp, "home") | |||
| 41 | + | require.NoError(t, os.Mkdir(home, 0o755)) | |||
| 42 | + | ||||
| 43 | + | unrelated := filepath.Join(temp, "unrelated") | |||
| 44 | + | require.NoError(t, os.Mkdir(unrelated, 0o755)) | |||
| 45 | + | unrelatedSentinel := filepath.Join(unrelated, "keep") | |||
| 46 | + | require.NoError(t, os.WriteFile(unrelatedSentinel, []byte("do not delete"), 0o600)) | |||
| 47 | + | ||||
| 48 | + | safeParent := filepath.Join(temp, "safe") | |||
| 49 | + | require.NoError(t, os.Mkdir(safeParent, 0o755)) | |||
| 50 | + | parentSentinel := filepath.Join(safeParent, "keep") | |||
| 51 | + | require.NoError(t, os.WriteFile(parentSentinel, []byte("do not delete"), 0o600)) | |||
| 52 | + | ||||
| 53 | + | external := filepath.Join(temp, "external") | |||
| 54 | + | require.NoError(t, os.Mkdir(external, 0o755)) | |||
| 55 | + | externalSentinel := filepath.Join(external, "keep") | |||
| 56 | + | require.NoError(t, os.WriteFile(externalSentinel, []byte("do not delete"), 0o600)) | |||
| 57 | + | link := filepath.Join(temp, "link") | |||
| 58 | + | require.NoError(t, os.Symlink(external, link)) | |||
| 59 | + | ||||
| 60 | + | for _, test := range []struct { | |||
| 61 | + | name string | |||
| 62 | + | output string | |||
| 63 | + | verify func(t *testing.T) | |||
| 64 | + | }{ | |||
| 65 | + | {name: "empty", output: ""}, | |||
| 66 | + | {name: "root", output: "/"}, | |||
| 67 | + | {name: "home", output: home}, | |||
| 68 | + | {name: "existing unrelated directory", output: unrelated, verify: func(t *testing.T) { | |||
| 69 | + | requireFileContents(t, unrelatedSentinel, "do not delete") | |||
| 70 | + | }}, | |||
| 71 | + | {name: "parent traversal", output: safeParent + "/../escaped", verify: func(t *testing.T) { | |||
| 72 | + | requireFileContents(t, parentSentinel, "do not delete") | |||
| 73 | + | require.NoFileExists(t, filepath.Join(temp, "escaped")) | |||
| 74 | + | }}, | |||
| 75 | + | {name: "symlink escape", output: filepath.Join(link, "escaped"), verify: func(t *testing.T) { | |||
| 76 | + | requireFileContents(t, externalSentinel, "do not delete") | |||
| 77 | + | require.NoFileExists(t, filepath.Join(external, "escaped")) | |||
| 78 | + | }}, | |||
| 79 | + | } { | |||
| 80 | + | t.Run(test.name, func(t *testing.T) { | |||
| 81 | + | cmd := exec.Command(filepath.Join(repoRoot, "scripts", "collect_first_presentation.sh")) | |||
| 82 | + | // An isolated directory prevents a regression from reaching the tracked | |||
| 83 | + | // default artifact path while this test proves output validation is first. | |||
| 84 | + | cmd.Dir = temp | |||
| 85 | + | cmd.Env = append(os.Environ(), | |||
| 86 | + | "HOME="+home, | |||
| 87 | + | "DISPLAY=:test", | |||
| 88 | + | "DUMBER_CEF_DIR="+runtime, | |||
| 89 | + | "DUMBER_FIRST_PRESENTATION_BIN="+binary, | |||
| 90 | + | "DUMBER_FIRST_PRESENTATION_OUTPUT="+test.output, | |||
| 91 | + | ) | |||
| 92 | + | result, err := cmd.CombinedOutput() | |||
| 93 | + | require.Errorf(t, err, "collector accepted unsafe output %q: %s", test.output, result) | |||
| 94 | + | require.Contains(t, string(result), "unsafe output path") | |||
| 95 | + | if test.verify != nil { | |||
| 96 | + | test.verify(t) | |||
| 97 | + | } | |||
| 98 | + | }) | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func requireFileContents(t *testing.T, path, want string) { | |||
| 103 | + | t.Helper() | |||
| 104 | + | contents, err := os.ReadFile(path) | |||
| 105 | + | require.NoError(t, err) | |||
| 106 | + | require.Equal(t, want, string(contents)) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 23 | func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) { | 109 | func TestFirstPresentationCollectorSanitizesMachineLocalValues(t *testing.T) { | ||
| 24 | repoRoot, err := filepath.Abs(filepath.Join("..", "..")) | 110 | repoRoot, err := filepath.Abs(filepath.Join("..", "..")) | ||
| 25 | require.NoError(t, err) | 111 | require.NoError(t, err) | ||
M scripts/collect_first_presentation.sh +46 -5
| @@ -8,11 +8,50 @@ readonly runs=5 | |||
| 8 | 8 | readonly timeout_seconds="${DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS:-45}" | |
| 9 | 9 | readonly runtime="${DUMBER_CEF_DIR:-$HOME/.local/share/cef-147-runtime}" | |
| 10 | 10 | readonly binary="${DUMBER_FIRST_PRESENTATION_BIN:-$PWD/dist/dumber}" | |
| 11 | - | readonly output="${DUMBER_FIRST_PRESENTATION_OUTPUT:-$PWD/phase1/first-presentation}" | |
| 11 | + | if [[ -v DUMBER_FIRST_PRESENTATION_OUTPUT ]]; then | |
| 12 | + | output="$DUMBER_FIRST_PRESENTATION_OUTPUT" | |
| 13 | + | else | |
| 14 | + | output="$PWD/phase1/first-presentation" | |
| 15 | + | fi | |
| 16 | + | readonly output | |
| 12 | 17 | readonly upstream_module="github.com/bnema/purego-cef2gtk" | |
| 13 | 18 | readonly upstream_tag="v0.8.4" | |
| 14 | 19 | readonly upstream_revision="f217ece342dea3ef2a3f98671fcd16a39ad0037d" | |
| 15 | 20 | ||
| 21 | + | fail_unsafe_output() { | |
| 22 | + | echo "first-presentation: unsafe output path: $1" >&2 | |
| 23 | + | exit 2 | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | # The artifact destination is caller-controlled. Never empty or recursively | |
| 27 | + | # clear it: collection only writes to a newly-created directory whose parent is | |
| 28 | + | # already canonical and contains no symbolic-link hop. | |
| 29 | + | prepare_output() { | |
| 30 | + | local parent name canonical_parent canonical_output | |
| 31 | + | ||
| 32 | + | [[ -n "$output" ]] || fail_unsafe_output "path must not be empty" | |
| 33 | + | [[ "$output" == /* ]] || fail_unsafe_output "path must be absolute" | |
| 34 | + | [[ "$output" != "/" ]] || fail_unsafe_output "path must not be /" | |
| 35 | + | [[ "${output%/}" != "${HOME%/}" ]] || fail_unsafe_output "path must not be HOME" | |
| 36 | + | [[ "$output" != .. && "$output" != ../* && "$output" != */.. && "$output" != */../* ]] || \ | |
| 37 | + | fail_unsafe_output "path must not contain parent traversal" | |
| 38 | + | [[ ! -e "$output" && ! -L "$output" ]] || fail_unsafe_output "path must be a fresh directory" | |
| 39 | + | ||
| 40 | + | parent="$(dirname -- "$output")" | |
| 41 | + | name="$(basename -- "$output")" | |
| 42 | + | [[ -n "$name" && "$name" != "." && "$name" != ".." ]] || fail_unsafe_output "invalid directory name" | |
| 43 | + | [[ -d "$parent" && ! -L "$parent" ]] || fail_unsafe_output "parent must be an existing directory" | |
| 44 | + | canonical_parent="$(realpath -e -- "$parent")" || fail_unsafe_output "parent cannot be canonicalized" | |
| 45 | + | [[ "$parent" == "$canonical_parent" ]] || fail_unsafe_output "parent must not contain symbolic links" | |
| 46 | + | ||
| 47 | + | mkdir -- "$output" || fail_unsafe_output "could not create fresh directory" | |
| 48 | + | canonical_output="$(realpath -e -- "$output")" || fail_unsafe_output "created directory cannot be canonicalized" | |
| 49 | + | [[ -d "$output" && ! -L "$output" && "$canonical_output" == "$canonical_parent/$name" ]] || \ | |
| 50 | + | fail_unsafe_output "created directory changed unexpectedly" | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | prepare_output | |
| 54 | + | ||
| 16 | 55 | [[ -x "$binary" ]] || { echo "first-presentation: executable not found: $binary" >&2; exit 2; } | |
| 17 | 56 | [[ -d "$runtime" ]] || { echo "first-presentation: CEF runtime not found: $runtime" >&2; exit 2; } | |
| 18 | 57 | [[ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]] || { echo "first-presentation: a current Wayland/X11 display is required" >&2; exit 2; } | |
| @@ -21,12 +60,14 @@ readonly upstream_revision="f217ece342dea3ef2a3f98671fcd16a39ad0037d" | |||
| 21 | 60 | exit 2 | |
| 22 | 61 | } | |
| 23 | 62 | ||
| 24 | - | rm -rf "$output" | |
| 25 | - | mkdir -p "$output" | |
| 26 | 63 | # Raw logs and temporary XDG homes may contain machine-local paths. Keep them | |
| 27 | 64 | # outside the committed artifact directory and always remove them. | |
| 28 | - | work_root="$(mktemp -d "${TMPDIR:-/tmp}/dumber-first-presentation.XXXXXX")" | |
| 29 | - | trap 'rm -rf "$work_root"' EXIT | |
| 65 | + | readonly work_root="$(mktemp -d "${TMPDIR:-/tmp}/dumber-first-presentation.XXXXXX")" | |
| 66 | + | cleanup_work_root() { | |
| 67 | + | [[ -n "${work_root:-}" && -d "$work_root" && ! -L "$work_root" ]] || return | |
| 68 | + | rm -rf -- "$work_root" | |
| 69 | + | } | |
| 70 | + | trap cleanup_work_root EXIT | |
| 30 | 71 | python3 - "$output/metadata.json" "$binary" "$timeout_seconds" "$upstream_module" "$upstream_tag" "$upstream_revision" <<'PY' | |
| 31 | 72 | import hashlib, json, os, subprocess, sys | |
| 32 | 73 | path, binary, timeout, upstream_module, upstream_tag, upstream_revision = sys.argv[1:] | |
| @@ -8,11 +8,50 @@ readonly runs=5 | |||||
| 8 | readonly timeout_seconds="${DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS:-45}" | 8 | readonly timeout_seconds="${DUMBER_FIRST_PRESENTATION_TIMEOUT_SECONDS:-45}" | ||
| 9 | readonly runtime="${DUMBER_CEF_DIR:-$HOME/.local/share/cef-147-runtime}" | 9 | readonly runtime="${DUMBER_CEF_DIR:-$HOME/.local/share/cef-147-runtime}" | ||
| 10 | readonly binary="${DUMBER_FIRST_PRESENTATION_BIN:-$PWD/dist/dumber}" | 10 | readonly binary="${DUMBER_FIRST_PRESENTATION_BIN:-$PWD/dist/dumber}" | ||
| 11 | - | readonly output="${DUMBER_FIRST_PRESENTATION_OUTPUT:-$PWD/phase1/first-presentation}" | 11 | + | if [[ -v DUMBER_FIRST_PRESENTATION_OUTPUT ]]; then |
| 12 | + | output="$DUMBER_FIRST_PRESENTATION_OUTPUT" | |||
| 13 | + | else | |||
| 14 | + | output="$PWD/phase1/first-presentation" | |||
| 15 | + | fi | |||
| 16 | + | readonly output | |||
| 12 | readonly upstream_module="github.com/bnema/purego-cef2gtk" | 17 | readonly upstream_module="github.com/bnema/purego-cef2gtk" | ||
| 13 | readonly upstream_tag="v0.8.4" | 18 | readonly upstream_tag="v0.8.4" | ||
| 14 | readonly upstream_revision="f217ece342dea3ef2a3f98671fcd16a39ad0037d" | 19 | readonly upstream_revision="f217ece342dea3ef2a3f98671fcd16a39ad0037d" | ||
| 15 | 20 | ||||
| 21 | + | fail_unsafe_output() { | |||
| 22 | + | echo "first-presentation: unsafe output path: $1" >&2 | |||
| 23 | + | exit 2 | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | # The artifact destination is caller-controlled. Never empty or recursively | |||
| 27 | + | # clear it: collection only writes to a newly-created directory whose parent is | |||
| 28 | + | # already canonical and contains no symbolic-link hop. | |||
| 29 | + | prepare_output() { | |||
| 30 | + | local parent name canonical_parent canonical_output | |||
| 31 | + | ||||
| 32 | + | [[ -n "$output" ]] || fail_unsafe_output "path must not be empty" | |||
| 33 | + | [[ "$output" == /* ]] || fail_unsafe_output "path must be absolute" | |||
| 34 | + | [[ "$output" != "/" ]] || fail_unsafe_output "path must not be /" | |||
| 35 | + | [[ "${output%/}" != "${HOME%/}" ]] || fail_unsafe_output "path must not be HOME" | |||
| 36 | + | [[ "$output" != .. && "$output" != ../* && "$output" != */.. && "$output" != */../* ]] || \ | |||
| 37 | + | fail_unsafe_output "path must not contain parent traversal" | |||
| 38 | + | [[ ! -e "$output" && ! -L "$output" ]] || fail_unsafe_output "path must be a fresh directory" | |||
| 39 | + | ||||
| 40 | + | parent="$(dirname -- "$output")" | |||
| 41 | + | name="$(basename -- "$output")" | |||
| 42 | + | [[ -n "$name" && "$name" != "." && "$name" != ".." ]] || fail_unsafe_output "invalid directory name" | |||
| 43 | + | [[ -d "$parent" && ! -L "$parent" ]] || fail_unsafe_output "parent must be an existing directory" | |||
| 44 | + | canonical_parent="$(realpath -e -- "$parent")" || fail_unsafe_output "parent cannot be canonicalized" | |||
| 45 | + | [[ "$parent" == "$canonical_parent" ]] || fail_unsafe_output "parent must not contain symbolic links" | |||
| 46 | + | ||||
| 47 | + | mkdir -- "$output" || fail_unsafe_output "could not create fresh directory" | |||
| 48 | + | canonical_output="$(realpath -e -- "$output")" || fail_unsafe_output "created directory cannot be canonicalized" | |||
| 49 | + | [[ -d "$output" && ! -L "$output" && "$canonical_output" == "$canonical_parent/$name" ]] || \ | |||
| 50 | + | fail_unsafe_output "created directory changed unexpectedly" | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | prepare_output | |||
| 54 | + | ||||
| 16 | [[ -x "$binary" ]] || { echo "first-presentation: executable not found: $binary" >&2; exit 2; } | 55 | [[ -x "$binary" ]] || { echo "first-presentation: executable not found: $binary" >&2; exit 2; } | ||
| 17 | [[ -d "$runtime" ]] || { echo "first-presentation: CEF runtime not found: $runtime" >&2; exit 2; } | 56 | [[ -d "$runtime" ]] || { echo "first-presentation: CEF runtime not found: $runtime" >&2; exit 2; } | ||
| 18 | [[ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]] || { echo "first-presentation: a current Wayland/X11 display is required" >&2; exit 2; } | 57 | [[ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]] || { echo "first-presentation: a current Wayland/X11 display is required" >&2; exit 2; } | ||
| @@ -21,12 +60,14 @@ readonly upstream_revision="f217ece342dea3ef2a3f98671fcd16a39ad0037d" | |||||
| 21 | exit 2 | 60 | exit 2 | ||
| 22 | } | 61 | } | ||
| 23 | 62 | ||||
| 24 | - | rm -rf "$output" | |||
| 25 | - | mkdir -p "$output" | |||
| 26 | # Raw logs and temporary XDG homes may contain machine-local paths. Keep them | 63 | # Raw logs and temporary XDG homes may contain machine-local paths. Keep them | ||
| 27 | # outside the committed artifact directory and always remove them. | 64 | # outside the committed artifact directory and always remove them. | ||
| 28 | - | work_root="$(mktemp -d "${TMPDIR:-/tmp}/dumber-first-presentation.XXXXXX")" | 65 | + | readonly work_root="$(mktemp -d "${TMPDIR:-/tmp}/dumber-first-presentation.XXXXXX")" |
| 29 | - | trap 'rm -rf "$work_root"' EXIT | 66 | + | cleanup_work_root() { |
| 67 | + | [[ -n "${work_root:-}" && -d "$work_root" && ! -L "$work_root" ]] || return | |||
| 68 | + | rm -rf -- "$work_root" | |||
| 69 | + | } | |||
| 70 | + | trap cleanup_work_root EXIT | |||
| 30 | python3 - "$output/metadata.json" "$binary" "$timeout_seconds" "$upstream_module" "$upstream_tag" "$upstream_revision" <<'PY' | 71 | python3 - "$output/metadata.json" "$binary" "$timeout_seconds" "$upstream_module" "$upstream_tag" "$upstream_revision" <<'PY' | ||
| 31 | import hashlib, json, os, subprocess, sys | 72 | import hashlib, json, os, subprocess, sys | ||
| 32 | path, binary, timeout, upstream_module, upstream_tag, upstream_revision = sys.argv[1:] | 73 | path, binary, timeout, upstream_module, upstream_tag, upstream_revision = sys.argv[1:] | ||