diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index f51bf26dbe..0429c55dcd 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -5186,6 +5186,30 @@ curl configuration and have five-second connect/twenty-second overall bounds. `scripts/installtests/pulse_auto_update_test.go` own this proof. Signature, edition, consent, backup, service-restoration and installation gates remain separate and unchanged. +### Manual update channel admission (7 October 2026) + +The installed `update` helper selects a saved release channel only from one +complete `system.json` object and its top-level `updateChannel`. Missing, null +or empty channels retain the stable default; recognised strings are trimmed +and case-normalised. Nested or quoted settings cannot opt into RC. Malformed, +multiple, non-object or unknown-channel configuration, a non-regular path or +missing jq stops before downloading or executing the installer, with explicit +`--stable`/`--rc` recovery guidance and no configuration rewrite. A missing file +retains the default. Explicit existing selectors (including `--prerelease`) +and the existing source-build marker keep precedence over saved configuration. +The helper forwards both parsed channels explicitly, including the stable +default for an existing file, so the containing installer cannot reinterpret +that same configuration through its older saved-preference reader. + +`TestManualUpdateChannelAdmission` generates and executes the actual helper +against local transport fixtures and genuinely signed installer/sidecar bytes. +It checks final arguments and the actual installer channel-selection functions, +pre-download refusal, configuration preservation, selector/marker precedence +and signature rejection. This is bounded source +acceptance, not installed upgrade, native service restoration or published +release acceptance. The installer, archive, signing, rollback and service gates +remain unchanged. + That same boundary also owns operator-facing management entry points for existing self-hosted installs: the installer's printed update/reset/uninstall commands and the active install or upgrade docs must route supported diff --git a/install.sh b/install.sh index 1e3b05be86..e2678b30ba 100755 --- a/install.sh +++ b/install.sh @@ -4042,7 +4042,7 @@ auto_selector_allowed=true if [[ \${#helper_args[@]} -gt 0 ]]; then for helper_arg in "\${helper_args[@]}"; do case "\$helper_arg" in - -h|--help|--uninstall|--version|--rc|--pre|--stable|--source|--from-source|--branch|--archive|--archive=*|--skip-upgrade-preflight) + -h|--help|--uninstall|--version|--rc|--pre|--prerelease|--stable|--source|--from-source|--branch|--archive|--archive=*|--skip-upgrade-preflight) auto_selector_allowed=false break ;; @@ -4055,10 +4055,34 @@ if [[ "\$auto_selector_allowed" == "true" ]]; then if [[ -n "\$branch" ]]; then extra_args+=(--source "\$branch") fi - elif [[ -f "\${CONFIG_DIR}/system.json" ]]; then - configured_channel=\$(grep -o '"updateChannel"[[:space:]]*:[[:space:]]*"[^"]*"' "\${CONFIG_DIR}/system.json" 2>/dev/null | sed 's/.*"\([^"]*\)"$/\1/' || true) + elif [[ -e "\${CONFIG_DIR}/system.json" || -L "\${CONFIG_DIR}/system.json" ]]; then + # Never infer a release channel from nested, quoted or partial JSON. + # Missing/null channels retain the stable default; malformed or unknown + # preferences stop before download. An explicit selector above wins. + if [[ ! -f "\${CONFIG_DIR}/system.json" ]] || ! command -v jq >/dev/null 2>&1; then + echo "Cannot read the saved update channel; a regular system.json and jq are required. Select --stable or --rc explicitly, or restore the configuration." >&2 + exit 1 + fi + if ! configured_channel=\$(jq -er -s ' + if length != 1 or (.[0] | type) != "object" then + error("expected one configuration object") + else .[0] end + | .updateChannel + | if . == null then "stable" else . end + | if type != "string" then error("invalid update channel") else . end + | gsub("^\\\\s+|\\\\s+\$"; "") | ascii_downcase + | if . == "" then "stable" else . end + | if . == "stable" or . == "rc" then . else error("unknown update channel") end + ' "\${CONFIG_DIR}/system.json" 2>/dev/null); then + echo "Cannot parse the saved update channel; no installer downloaded. Select --stable or --rc explicitly, or restore the configuration." >&2 + exit 1 + fi if [[ "\$configured_channel" == "rc" ]]; then extra_args+=(--rc) + else + # Bind the parsed default too: the installer has its own saved + # preference reader, which must not re-interpret the same file. + extra_args+=(--stable) fi fi fi diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index 0251e9bddc..55efa1a672 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -4366,7 +4366,7 @@ func TestSetupUpdateCommandHonorsRCChannelAndCustomPaths(t *testing.T) { if !strings.Contains(got, `helper_args=()`) || !strings.Contains(got, `helper_args=("$@")`) { t.Fatalf("update helper missing passthrough helper args:\n%s", got) } - if !strings.Contains(got, `-h|--help|--uninstall|--version|--rc|--pre|--stable|--source|--from-source|--branch|--archive|--archive=*|--skip-upgrade-preflight)`) { + if !strings.Contains(got, `-h|--help|--uninstall|--version|--rc|--pre|--prerelease|--stable|--source|--from-source|--branch|--archive|--archive=*|--skip-upgrade-preflight)`) { t.Fatalf("update helper missing auto-selector guard for explicit flags:\n%s", got) } if !strings.Contains(got, `extra_args+=("${helper_args[@]}")`) { @@ -4375,6 +4375,9 @@ func TestSetupUpdateCommandHonorsRCChannelAndCustomPaths(t *testing.T) { if !strings.Contains(got, `extra_args+=(--rc)`) { t.Fatalf("update helper missing rc channel forwarding:\n%s", got) } + if !strings.Contains(got, `extra_args+=(--stable)`) { + t.Fatalf("update helper must bind the parsed stable channel before the installer rereads configuration:\n%s", got) + } if !strings.Contains(got, `INSTALLER_URL="https://github.com/example/pulse-fork/releases/latest/download/install.sh"`) { t.Fatalf("update helper missing configured repo installer url:\n%s", got) } diff --git a/scripts/installtests/manual_update_channel_test.go b/scripts/installtests/manual_update_channel_test.go new file mode 100644 index 0000000000..13efa987f5 --- /dev/null +++ b/scripts/installtests/manual_update_channel_test.go @@ -0,0 +1,244 @@ +package installtests + +import ( + "os" + "os/exec" + "path/filepath" + "reflect" + "regexp" + "strings" + "testing" +) + +// Exercise the generated helper, not a second copy of its channel parser. The +// transport is local-only, but the installer and sidecar use real SSH signatures. +func TestManualUpdateChannelAdmission(t *testing.T) { + for _, tool := range []string{"bash", "jq", "ssh-keygen"} { + if _, err := exec.LookPath(tool); err != nil { + t.Fatalf("manual update proof requires %s: %v", tool, err) + } + } + source, err := filepath.Abs(filepath.Join("..", "..", "install.sh")) + if err != nil { + t.Fatal(err) + } + bash, err := exec.LookPath("bash") + if err != nil { + t.Fatal(err) + } + + assets := t.TempDir() + key := filepath.Join(assets, "signing-key") + if out, err := exec.Command("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", key).CombinedOutput(); err != nil { + t.Fatalf("create fixture key: %v\n%s", err, out) + } + publicKey, err := os.ReadFile(key + ".pub") + if err != nil { + t.Fatal(err) + } + rootSource, err := os.ReadFile(source) + if err != nil { + t.Fatal(err) + } + selectionFunctions := "" + for _, name := range []string{"read_configured_update_channel", "selected_update_channel"} { + body := regexp.MustCompile(`(?ms)^` + name + `\(\) \{\n.*?^\}`).Find(rootSource) + if body == nil { + t.Fatalf("cannot find actual installer channel function %s", name) + } + selectionFunctions += string(body) + "\n" + } + installer := filepath.Join(assets, "install.sh") + // The real installer reads configuration again unless the helper forwards + // an explicit channel. Use its actual selection functions to catch that + // crossing rather than accepting helper arguments alone as the outcome. + fixture := `#!/bin/bash +set -eu +CONFIG_DIR="$PULSE_CONFIG_DIR" +FORCE_CHANNEL="" +FORCE_VERSION="" +UPDATE_CHANNEL="" +IGNORE_CONFIGURED_UPDATE_CHANNEL=false +` + selectionFunctions + ` +for arg in "$@"; do + case "$arg" in + --stable) FORCE_CHANNEL=stable ;; + --rc|--pre|--prerelease) FORCE_CHANNEL=rc ;; + esac +done +printf '%s\n' EXECUTED "$@" > "$MANUAL_UPDATE_CALLS" +selected_update_channel > "$MANUAL_UPDATE_CHANNEL" +` + if err := os.WriteFile(installer, []byte(fixture), 0600); err != nil { + t.Fatal(err) + } + if out, err := exec.Command("ssh-keygen", "-q", "-Y", "sign", "-f", key, "-n", "pulse-install", installer).CombinedOutput(); err != nil { + t.Fatalf("sign fixture installer: %v\n%s", err, out) + } + + cases := []struct { + name, config, marker string + args, want []string + blocked bool + missingConfig bool + missingJQ bool + directory bool + brokenLink bool + badSignature bool + }{ + {name: "stable", config: `{"updateChannel":"stable"}`, want: []string{"--stable"}}, + {name: "rc", config: `{"updateChannel":"rc"}`, want: []string{"--rc"}}, + {name: "normalized_rc", config: `{"updateChannel":" RC "}`, want: []string{"--rc"}}, + {name: "normalized_stable", config: `{"updateChannel":" STABLE "}`, want: []string{"--stable"}}, + {name: "missing_setting", config: `{}`, want: []string{"--stable"}}, + {name: "null_setting", config: `{"updateChannel":null}`, want: []string{"--stable"}}, + {name: "empty_setting", config: `{"updateChannel":" "}`, want: []string{"--stable"}}, + {name: "missing_file", missingConfig: true}, + {name: "nested_only", config: `{"other":{"updateChannel":"rc"}}`, want: []string{"--stable"}}, + {name: "stable_with_nested_rc", config: `{"updateChannel":"stable","other":{"updateChannel":"rc"}}`, want: []string{"--stable"}}, + {name: "rc_with_nested_stable", config: `{"updateChannel":"rc","other":{"updateChannel":"stable"}}`, want: []string{"--rc"}}, + {name: "escaped_setting_name", config: `{"update\u0043hannel":"rc"}`, want: []string{"--rc"}}, + {name: "quoted_only", config: `{"note":"\"updateChannel\":\"rc\""}`, want: []string{"--stable"}}, + {name: "truncated", config: `{"updateChannel":"rc"`, blocked: true}, + {name: "valid_object_then_garbage", config: `{"updateChannel":"rc"} trailing`, blocked: true}, + {name: "two_objects", config: `{"updateChannel":"stable"} {"updateChannel":"rc"}`, blocked: true}, + {name: "empty_file", blocked: true}, + {name: "array", config: `[{"updateChannel":"rc"}]`, blocked: true}, + {name: "string", config: `"rc"`, blocked: true}, + {name: "boolean", config: `{"updateChannel":false}`, blocked: true}, + {name: "number", config: `{"updateChannel":1}`, blocked: true}, + {name: "channel_array", config: `{"updateChannel":["rc"]}`, blocked: true}, + {name: "unknown_channel", config: `{"updateChannel":"beta"}`, blocked: true}, + {name: "missing_jq", config: `{"updateChannel":"rc"}`, missingJQ: true, blocked: true}, + {name: "directory", directory: true, blocked: true}, + {name: "broken_link", brokenLink: true, blocked: true}, + {name: "explicit_stable_over_rc", config: `{"updateChannel":"rc"}`, args: []string{"--stable"}, want: []string{"--stable"}}, + {name: "explicit_rc_over_stable", config: `{"updateChannel":"stable"}`, args: []string{"--rc"}, want: []string{"--rc"}}, + {name: "explicit_prerelease_over_invalid", config: `{"updateChannel":false}`, args: []string{"--prerelease"}, want: []string{"--prerelease"}}, + {name: "explicit_version_over_invalid", config: `{`, args: []string{"--version", "v6.5.0"}, want: []string{"--version", "v6.5.0"}}, + {name: "explicit_source_over_invalid", config: `{`, args: []string{"--source", "main"}, want: []string{"--source", "main"}}, + {name: "explicit_archive_over_invalid", config: `{`, args: []string{"--archive", "/tmp/signed-release.tar.gz"}, want: []string{"--archive", "/tmp/signed-release.tar.gz"}}, + {name: "ordinary_options_keep_rc", config: `{"updateChannel":"rc"}`, args: []string{"--no-auto-update"}, want: []string{"--rc", "--no-auto-update"}}, + {name: "source_marker_over_invalid", config: `{`, marker: "maintenance\n", want: []string{"--source", "maintenance"}}, + {name: "explicit_channel_over_source_marker", config: `{`, marker: "main\n", args: []string{"--stable"}, want: []string{"--stable"}}, + {name: "signature_still_required", config: `{"updateChannel":"rc"}`, badSignature: true, blocked: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + configDir := filepath.Join(dir, "config") + if err := os.Mkdir(configDir, 0700); err != nil { + t.Fatal(err) + } + configPath := filepath.Join(configDir, "system.json") + switch { + case tc.directory: + err = os.Mkdir(configPath, 0700) + case tc.brokenLink: + err = os.Symlink(filepath.Join(dir, "absent"), configPath) + case !tc.missingConfig: + err = os.WriteFile(configPath, []byte(tc.config), 0600) + } + if err != nil { + t.Fatal(err) + } + if tc.marker != "" { + if err := os.WriteFile(filepath.Join(dir, "BUILD_FROM_SOURCE"), []byte(tc.marker), 0600); err != nil { + t.Fatal(err) + } + } + helper := filepath.Join(dir, "update") + generate := exec.Command(bash, "-c", `source "$INSTALLER_SOURCE" +PINNED_RELEASE_SSH_PUBLIC_KEY="$FIXTURE_PUBLIC_KEY" +setup_update_command +`) + generate.Env = append(os.Environ(), "INSTALLER_SOURCE="+source, "FIXTURE_PUBLIC_KEY="+strings.TrimSpace(string(publicKey)), + "PULSE_INSTALL_DIR="+dir, "PULSE_CONFIG_DIR="+configDir, "PULSE_UPDATE_HELPER_PATH="+helper, + "PULSE_PROFILE_PATH="+filepath.Join(dir, "profile"), "PULSE_BASHRC_PATH="+filepath.Join(dir, "bashrc")) + if out, err := generate.CombinedOutput(); err != nil { + t.Fatalf("generate helper: %v\n%s", err, out) + } + if out, err := exec.Command(bash, "-n", helper).CombinedOutput(); err != nil { + t.Fatalf("generated Bash syntax: %v\n%s", err, out) + } + curl := `#!/bin/bash +set -eu +url="" +dest="" +while [[ $# -gt 0 ]]; do + case "$1" in + -o) dest="$2"; shift 2 ;; + https://github.com/rcourtman/Pulse/releases/latest/download/install.sh*) url="$1"; shift ;; + -fsSL) shift ;; + *) exit 98 ;; + esac +done +[[ -n "$url" && -n "$dest" ]] || exit 97 +printf '%s\n' "$url" >> "$MANUAL_UPDATE_TRANSPORT" +case "$url" in + *.sshsig) if [[ "$BAD_SIGNATURE" == true ]]; then printf invalid > "$dest"; else cp "$FIXTURE_ASSETS/install.sh.sig" "$dest"; fi ;; + */install.sh) cp "$FIXTURE_ASSETS/install.sh" "$dest" ;; + *) exit 96 ;; +esac +` + if err := os.WriteFile(filepath.Join(dir, "curl"), []byte(curl), 0700); err != nil { + t.Fatal(err) + } + path := dir + string(os.PathListSeparator) + os.Getenv("PATH") + if tc.missingJQ { + path = dir // parser fails before any non-builtin operation + } + calls := filepath.Join(dir, "calls") + transport := filepath.Join(dir, "transport") + channel := filepath.Join(dir, "channel") + cmd := exec.Command(bash, append([]string{helper}, tc.args...)...) + cmd.Env = append(os.Environ(), "PATH="+path, "FIXTURE_ASSETS="+assets, "MANUAL_UPDATE_CALLS="+calls, "MANUAL_UPDATE_TRANSPORT="+transport, "MANUAL_UPDATE_CHANNEL="+channel, + "BAD_SIGNATURE="+map[bool]string{true: "true", false: "false"}[tc.badSignature]) + out, runErr := cmd.CombinedOutput() + if tc.blocked { + if runErr == nil { + t.Fatalf("invalid preference/signature admitted: %s", out) + } + if _, err := os.Stat(calls); !os.IsNotExist(err) { + t.Fatalf("rejected helper executed installer: %v\n%s", err, out) + } + if !tc.badSignature { + if _, err := os.Stat(transport); !os.IsNotExist(err) { + t.Fatalf("invalid preference reached download: %v\n%s", err, out) + } + if !strings.Contains(string(out), "saved update channel") || !strings.Contains(string(out), "--stable or --rc") { + t.Fatalf("missing safe recovery guidance: %s", out) + } + } + } else { + if runErr != nil { + t.Fatalf("valid helper failed: %v\n%s", runErr, out) + } + got, err := os.ReadFile(calls) + if err != nil { + t.Fatal(err) + } + want := append([]string{"EXECUTED"}, tc.want...) + if !reflect.DeepEqual(strings.Split(strings.TrimSuffix(string(got), "\n"), "\n"), want) { + t.Fatalf("installer args %q, want %q", got, want) + } + selected, err := os.ReadFile(channel) + wantChannel := "stable" + for _, arg := range tc.want { + if arg == "--rc" || arg == "--prerelease" { + wantChannel = "rc" + } + } + if err != nil || strings.TrimSpace(string(selected)) != wantChannel { + t.Fatalf("installer selected %q: %v, want %s", selected, err, wantChannel) + } + } + if !tc.missingConfig && !tc.directory && !tc.brokenLink { + got, err := os.ReadFile(configPath) + if err != nil || string(got) != tc.config { + t.Fatalf("helper changed configuration: %v, %q", err, got) + } + } + }) + } +}