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
79 changes: 79 additions & 0 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ BIN_NAME="pickscribe-app"
APP_ID="pickscribe-app"
WM_CLASS="Pickscribe-app"

# Tauri updater signing key (src-tauri/tauri.conf.json -> plugins.updater.pubkey,
# base64-decoded to its minisign public-key line). Embedded so the installer
# never fetches the trust root over the network. Key id: C9FDD29AA6D3DEA1.
readonly MINISIGN_PUBKEY="RWSh3tOmmtL9yYOe9M6YhBqmVJx3TibwJaHXYq4YbYKONVfjlBdKqRk6"

# Environment overrides:
# PICKSCRIBE_INSTALL_DIR Wrapper/AppImage directory. Default: $HOME/.local/bin.
# PICKSCRIBE_VERSION Install a specific release tag, such as v0.1.0.
Expand Down Expand Up @@ -248,6 +253,79 @@ download_asset() {
[ -s "$asset_path" ] || die "downloaded asset is empty: $asset_name"
}

select_signature_verifier() {
if command -v minisign >/dev/null 2>&1; then
verifier_cmd="minisign"
elif command -v rsign >/dev/null 2>&1; then
verifier_cmd="rsign"
else
verifier_cmd=""
fi
}

warn_signature_verification_skipped() {
printf '\n' >&2
printf '!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n' >&2
printf '!! WARNING: signature verification skipped for %s\n' "$asset_name" >&2
printf '!!\n' >&2
printf '!! No minisign-compatible verifier (minisign or rsign) was found on\n' >&2
printf '!! PATH, so the downloaded release asset could not be authenticated\n' >&2
printf '!! before install. Install a verifier and re-run to enable this check:\n' >&2
printf '!! macOS: brew install minisign\n' >&2
printf '!! Linux: apt install minisign (or your distro equivalent)\n' >&2
printf '!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n' >&2
printf '\n' >&2
}

# BSD base64 (older macOS) only accepts `-D` for decode; GNU base64 (Linux)
# and modern macOS accept `-d`. Try `-d` first, then fall back to `-D`, so
# decoding works across both without misdetecting the platform.
decode_base64_to() {
decode_src=$1
decode_dest=$2

base64 -d < "$decode_src" > "$decode_dest" 2>/dev/null ||
base64 -D < "$decode_src" > "$decode_dest" 2>/dev/null
}

# Verifies the downloaded asset against its `<asset>.sig` companion (a
# base64-wrapped minisign signature, as published by the Tauri updater
# bundler) before the archive is parsed or installed. If a compatible
# verifier is on PATH, verification is mandatory and fails closed: a missing,
# undecodable, or invalid signature aborts the install. If no verifier is
# available, this prints a loud warning and lets the install proceed.
verify_asset_signature() {
select_signature_verifier
if [ -z "$verifier_cmd" ]; then
warn_signature_verification_skipped
return 0
fi

sig_encoded_path="$asset_path.sig.b64"
sig_path="$asset_path.minisig"

download_to "$asset_url.sig" "$sig_encoded_path" ||
die "failed to download the signature for $asset_name; refusing to install an unverified asset (found $verifier_cmd on PATH, so verification is mandatory)"
[ -s "$sig_encoded_path" ] || die "downloaded signature for $asset_name is empty"

decode_base64_to "$sig_encoded_path" "$sig_path" ||
die "failed to decode the signature for $asset_name"
[ -s "$sig_path" ] || die "decoded signature for $asset_name is empty"

case "$verifier_cmd" in
minisign)
minisign -V -q -m "$asset_path" -x "$sig_path" -P "$MINISIGN_PUBKEY" ||
die "signature verification failed for $asset_name; aborting install"
;;
rsign)
rsign verify "$asset_path" -P "$MINISIGN_PUBKEY" -x "$sig_path" -q ||
die "signature verification failed for $asset_name; aborting install"
;;
esac

printf '%s signature verified (%s).\n' "$asset_name" "$verifier_cmd"
}

desktop_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/`/\\`/g; s/\$/\\$/g; s/%/%%/g'
}
Expand Down Expand Up @@ -559,6 +637,7 @@ main() {
trap 'exit 130' INT
trap 'exit 143' TERM
download_asset
verify_asset_signature
if [ "$platform" = "macos" ]; then
install_macos_app
else
Expand Down
166 changes: 148 additions & 18 deletions tests/install-script-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import { join } from "node:path";
const repoRoot = new URL("..", import.meta.url).pathname;
const installer = join(repoRoot, "scripts", "install.sh");

// Deliberately excludes the host's own PATH (eg. Homebrew's /opt/homebrew/bin):
// tests must not pass or fail depending on whether the developer's machine
// happens to have a real minisign/rsign installed. Verifier presence is
// controlled per test via fakebin.
const STANDARD_PATH = "/usr/bin:/bin:/usr/sbin:/sbin";

function makeTempRoot(name) {
const root = execFileSync("mktemp", ["-d", join(tmpdir(), `${name}.XXXXXX`)], {
encoding: "utf8",
Expand Down Expand Up @@ -39,9 +45,18 @@ exit 0
`,
);
writeMacBundleFixture(fixture, "first bundle");
writeSignatureFixtures(fixture);
return fixture;
}

// The real installer decodes `<asset>.sig` as base64 before handing it to
// minisign/rsign. Content doesn't matter here since the fake verifier below
// never actually parses it -- it only needs to decode to something non-empty.
function writeSignatureFixtures(fixture) {
writeFileSync(join(fixture, "PickScribe_9.9.9_amd64.AppImage.sig"), Buffer.from("fake-sig-amd64").toString("base64"));
writeFileSync(join(fixture, "PickScribe_9.9.9_aarch64.app.tar.gz.sig"), Buffer.from("fake-sig-aarch64").toString("base64"));
}

function writeMacBundleFixture(fixture, marker) {
const bundleRoot = join(fixture, "bundle");
const bundleBinary = join(bundleRoot, "PickScribe.app", "Contents", "MacOS", "pickscribe-app");
Expand Down Expand Up @@ -124,6 +139,12 @@ case "$url" in
*.AppImage|*.app.tar.gz)
cp "$PICKSCRIBE_TEST_FIXTURE/\${url##*/}" "$out"
;;
*.sig)
if [ ! -f "$PICKSCRIBE_TEST_FIXTURE/\${url##*/}" ]; then
exit 22
fi
cp "$PICKSCRIBE_TEST_FIXTURE/\${url##*/}" "$out"
;;
*)
echo "unexpected url: $url" >&2
exit 64
Expand All @@ -133,24 +154,72 @@ esac
);
}

function runInstaller(root, fixture, extraEnv = {}) {
const fakebin = join(root, "fakebin");
mkdirSync(fakebin, { recursive: true });
writeFakeCurl(fakebin);
writeFakeUname(fakebin);
writeFakeSysctl(fakebin);
// Writes a `base64` that rejects `-d` (like older BSD/macOS base64, which
// only supports `-D`) so tests can prove install.sh's decode_base64_to()
// actually falls back to `-D` instead of assuming GNU-style `-d` support.
function writeFakeBase64DOnly(fakebin) {
writeExecutable(
join(fakebin, "base64"),
`#!/bin/sh
case "\${1:-}" in
-d)
exit 1
;;
-D)
cat
exit 0
;;
*)
exit 64
;;
esac
`,
);
}

const env = {
// Writes a fake `minisign` (or `rsign`) that records the arguments it was
// called with and exits with `exitCode`, standing in for real signature
// verification so tests can force the pass/fail outcome deterministically.
function writeFakeVerifier(fakebin, { name, exitCode, callLog }) {
writeExecutable(
join(fakebin, name),
`#!/bin/sh
printf '%s\\n' "$*" >> "${callLog}"
exit ${exitCode}
`,
);
}

function baseEnv(root, fixture, extraEnv) {
return {
...process.env,
HOME: join(root, "home"),
XDG_DATA_HOME: join(root, "home", ".local", "share"),
PATH: `${fakebin}:${process.env.PATH}`,
PICKSCRIBE_TEST_FIXTURE: fixture,
PICKSCRIBE_RELEASE_API_URL: "https://release.test/latest",
PICKSCRIBE_TEST_OS: "Linux",
PICKSCRIBE_TEST_ARCH: "x86_64",
...extraEnv,
};
}

function runInstaller(root, fixture, extraEnv = {}, { verifier, fakeBase64DOnly } = {}) {
const fakebin = join(root, "fakebin");
mkdirSync(fakebin, { recursive: true });
writeFakeCurl(fakebin);
writeFakeUname(fakebin);
writeFakeSysctl(fakebin);
if (verifier) {
writeFakeVerifier(fakebin, verifier);
}
if (fakeBase64DOnly) {
writeFakeBase64DOnly(fakebin);
}

const env = {
...baseEnv(root, fixture, extraEnv),
PATH: `${fakebin}:${STANDARD_PATH}`,
};

return execFileSync("sh", [installer], {
cwd: repoRoot,
Expand All @@ -160,23 +229,22 @@ function runInstaller(root, fixture, extraEnv = {}) {
});
}

function runInstallerFailure(root, fixture, extraEnv = {}) {
function runInstallerFailure(root, fixture, extraEnv = {}, { verifier, fakeBase64DOnly } = {}) {
const fakebin = join(root, "fakebin");
mkdirSync(fakebin, { recursive: true });
writeFakeCurl(fakebin);
writeFakeUname(fakebin);
writeFakeSysctl(fakebin);
if (verifier) {
writeFakeVerifier(fakebin, verifier);
}
if (fakeBase64DOnly) {
writeFakeBase64DOnly(fakebin);
}

const env = {
...process.env,
HOME: join(root, "home"),
XDG_DATA_HOME: join(root, "home", ".local", "share"),
PATH: `${fakebin}:${process.env.PATH}`,
PICKSCRIBE_TEST_FIXTURE: fixture,
PICKSCRIBE_RELEASE_API_URL: "https://release.test/latest",
PICKSCRIBE_TEST_OS: "Linux",
PICKSCRIBE_TEST_ARCH: "x86_64",
...extraEnv,
...baseEnv(root, fixture, extraEnv),
PATH: `${fakebin}:${STANDARD_PATH}`,
};

return spawnSync("sh", [installer], {
Expand Down Expand Up @@ -325,3 +393,65 @@ test("AppImage install refuses to overwrite an unrelated command", (root) => {
assert.notEqual(result.status, 0);
assert.match(result.stderr, /command path already exists and was not created by PickScribe/);
});

test("verifier present + good signature installs and reports verification", (root) => {
const fixture = writeFixture(root);
const callLog = join(root, "minisign-calls.log");

const output = runInstaller(root, fixture, {}, { verifier: { name: "minisign", exitCode: 0, callLog } });

assert.match(output, /PickScribe_9\.9\.9_amd64\.AppImage signature verified \(minisign\)\./);
assert.equal(existsSync(join(root, "home", ".local", "bin", "PickScribe.AppImage")), true);
const calls = readFileSync(callLog, "utf8");
assert.match(calls, /-P RWSh3tOmmtL9yYOe9M6YhBqmVJx3TibwJaHXYq4YbYKONVfjlBdKqRk6/);
});

test("verifier present + -d-unsupported base64 falls back to -D and still verifies", (root) => {
const fixture = writeFixture(root);
const callLog = join(root, "minisign-calls.log");

const output = runInstaller(root, fixture, {}, {
verifier: { name: "minisign", exitCode: 0, callLog },
fakeBase64DOnly: true,
});

assert.match(output, /PickScribe_9\.9\.9_amd64\.AppImage signature verified \(minisign\)\./);
assert.equal(existsSync(join(root, "home", ".local", "bin", "PickScribe.AppImage")), true);
});

test("verifier present + bad signature aborts before install", (root) => {
const fixture = writeFixture(root);
const callLog = join(root, "minisign-calls.log");

const result = runInstallerFailure(root, fixture, {}, { verifier: { name: "minisign", exitCode: 1, callLog } });

assert.notEqual(result.status, 0);
assert.match(result.stderr, /signature verification failed for PickScribe_9\.9\.9_amd64\.AppImage; aborting install/);
assert.equal(existsSync(join(root, "home", ".local", "bin", "PickScribe.AppImage")), false);
});

test("verifier present + missing .sig aborts before install", (root) => {
const fixture = writeFixture(root);
rmSync(join(fixture, "PickScribe_9.9.9_amd64.AppImage.sig"));
const callLog = join(root, "minisign-calls.log");

const result = runInstallerFailure(root, fixture, {}, { verifier: { name: "minisign", exitCode: 0, callLog } });

assert.notEqual(result.status, 0);
assert.match(result.stderr, /failed to download the signature for PickScribe_9\.9\.9_amd64\.AppImage/);
assert.match(result.stderr, /verification is mandatory/);
assert.equal(existsSync(join(root, "home", ".local", "bin", "PickScribe.AppImage")), false);
assert.equal(existsSync(callLog), false);
});

test("no verifier on PATH warns loudly and proceeds with install", (root) => {
const fixture = writeFixture(root);

const result = runInstallerFailure(root, fixture);

assert.equal(result.status, 0);
assert.match(result.stderr, /WARNING: signature verification skipped for PickScribe_9\.9\.9_amd64\.AppImage/);
assert.match(result.stderr, /brew install minisign/);
assert.match(result.stderr, /apt install minisign/);
assert.equal(existsSync(join(root, "home", ".local", "bin", "PickScribe.AppImage")), true);
});