mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 21:47:20 +00:00
Integrate reviewed manual update channel admission
Accept the exact Delivery candidate and its complete source-s468dla1 installer proof. The final repair removes only contract EOF whitespace; installer and regression-test bytes are unchanged. Native installed recovery and protected publication remain separate. Change-source: pulse-maintainer
This commit is contained in:
@@ -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
|
||||
|
||||
+27
-3
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user