diff --git a/README.md b/README.md index 80c9da7..b1aac84 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ pnpm build:next 安装器正式支持 **Linux x86_64(x64)**,脚本和运行时也支持在对应原生 runner 上发布 **aarch64(arm64)**;当前仓库内置 workflow 只生成 x64,arm64 需要在原生 ARM64 runner 上单独构建并发布。ARMv7/ARM32 仅实验性支持;Linux x86 32 位(`i386`、`i686`、`ia32`)明确不支持,因为 Node.js 24 和项目原生依赖没有可维护的官方构建。不要在 32 位系统上强行安装。 -发布包必须包含 `dist/`、生产依赖、匹配架构的 Node runtime、systemd 单元,以及 `SHA256SUMS`。签名文件 `SHA256SUMS.sig` 是可选增强校验,不需要为普通安装准备公钥。安装器默认直接安装最新版本: +发布包必须包含 `dist/`、生产依赖、匹配架构的 Node runtime、systemd 单元、`uninstall.sh`,以及 `SHA256SUMS`。签名文件 `SHA256SUMS.sig` 是可选增强校验,不需要为普通安装准备公钥。安装器默认直接安装最新版本: ```bash curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash @@ -72,6 +72,26 @@ curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/ra 更新任务详情按发起管理员隔离;失败信息在浏览器中使用固定提示,不暴露服务器路径、命令输出或上游响应。系统同一时刻只允许一个更新任务。 +### 卸载 + +安装完成后会提供 `/usr/local/sbin/tallynote-uninstall`。普通卸载会停止并禁用 TallyNote 的 systemd 单元,删除当前版本、更新辅助程序和已知配置,但保留 `/var/lib/tallynote` 以及更新备份,方便以后重新安装: + +```bash +sudo /usr/local/sbin/tallynote-uninstall +``` + +如果确认不再需要数据库、附件、暂存、导出和更新备份,必须显式同时提供 `--purge-data --yes`: + +```bash +sudo /usr/local/sbin/tallynote-uninstall --purge-data --yes --purge-config +``` + +卸载检测到未完成的更新状态时会停止并要求人工确认;确认更新已停止后再加 `--force`。也可以直接从公开仓库获取同一脚本执行普通卸载: + +```bash +curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/uninstall.sh | sudo bash +``` + 公网反代必须使用 HTTPS,并在环境文件中设置真实的 `TALLYNOTE_PUBLIC_ORIGIN=https://...`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数(不要使用生产值 `true`)。 ### 构建发布包 @@ -80,17 +100,17 @@ curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/ra ```bash pnpm install --frozen-lockfile -pnpm release:build 1.1.1 ./release +pnpm release:build 1.1.2 ./release ``` 将生成的 `tallynote-<版本>-linux-<架构>-.tar.gz` 上传到同一个 Gitea Release。推荐由 `.gitea/workflows/release.yml` 自动执行 `scripts/publish-gitea-release.sh`,统一生成并上传 `SHA256SUMS`;如果 CI 提供签名私钥,还会额外上传 `SHA256SUMS.sig`。CI 只需要 `GITEA_TOKEN`;签名私钥属于可选增强。 -版本由 `package.json` 和 Git tag 双重约束:两者必须相同(例如 `1.1.1` 与 `v1.1.1`),workflow 会在构建前拒绝不一致的 tag。发布一个版本: +版本由 `package.json` 和 Git tag 双重约束:两者必须相同(例如 `1.1.2` 与 `v1.1.2`),workflow 会在构建前拒绝不一致的 tag。发布一个版本: ```bash git add . -git commit -m "release: 1.1.1" -git tag -a v1.1.1 -m "TallyNote 1.1.1" +git commit -m "release: 1.1.2" +git tag -a v1.1.2 -m "TallyNote 1.1.2" git push origin main --follow-tags ``` diff --git a/docs/release.md b/docs/release.md index 12435ab..1e0bdc1 100644 --- a/docs/release.md +++ b/docs/release.md @@ -6,7 +6,7 @@ TallyNote 的发布包必须在目标 Linux 架构上构建。`better-sqlite3` ## 自动发布 -向 Gitea 推送符合 SemVer 的 tag(例如 `v1.1.1`)会触发 `.gitea/workflows/release.yml`: +向 Gitea 推送符合 SemVer 的 tag(例如 `v1.1.2`)会触发 `.gitea/workflows/release.yml`: 1. 在 Linux runner 上安装依赖,执行 `pnpm check`、`pnpm test` 和 `pnpm release:build`。 2. 由 `scripts/publish-gitea-release.sh` 计算所有归档的 `SHA256SUMS`。 @@ -22,13 +22,13 @@ TallyNote 的发布包必须在目标 Linux 架构上构建。`better-sqlite3` ```bash pnpm install --frozen-lockfile pnpm check && pnpm test -pnpm release:build 1.1.1 ./release +pnpm release:build 1.1.2 ./release GITHUB_REPOSITORY=awaioi/TallyNote \ GITEA_TOKEN=... \ - ./scripts/publish-gitea-release.sh v1.1.1 ./release + ./scripts/publish-gitea-release.sh v1.1.2 ./release ``` -发布资产名称必须包含当前平台,例如 `tallynote-1.1.1-linux-x64-glibc.tar.gz`。同一个 Release 只保留一个 `SHA256SUMS`;有签名时再保留一个 `SHA256SUMS.sig`,签名覆盖清单完整原文。 +发布资产名称必须包含当前平台,例如 `tallynote-1.1.2-linux-x64-glibc.tar.gz`。同一个 Release 只保留一个 `SHA256SUMS`;有签名时再保留一个 `SHA256SUMS.sig`,签名覆盖清单完整原文。 ## curl 安装 @@ -44,7 +44,7 @@ curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/ra 已有安装默认拒绝安装不高于当前版本的 release;只有在明确执行 `--allow-downgrade`(或设置 `TALLYNOTE_ALLOW_DOWNGRADE=true`)时才允许回退版本。 -安装器拒绝预先存在的符号链接、非 root 拥有或对组/其他用户可写的安装、配置和备份目录。`--allow-unsigned` 作为旧版本兼容参数保留。 +安装器拒绝预先存在的符号链接、非 root 拥有或对组/其他用户可写的安装、配置和备份目录。发布包同时携带 `uninstall.sh`,安装后会落到 `/usr/local/sbin/tallynote-uninstall`。`--allow-unsigned` 作为旧版本兼容参数保留。 安装布局: @@ -58,6 +58,16 @@ curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/ra /etc/tallynote/tallynote.env ``` +## 卸载与数据保留 + +默认卸载只移除发布代码、systemd 单元、更新辅助程序和已知配置,数据目录与更新备份不会删除: + +```bash +sudo /usr/local/sbin/tallynote-uninstall +``` + +只有显式 `--purge-data --yes` 才会删除 SQLite、附件、暂存、导出、更新队列和备份;`--purge-config` 可在确认配置目录中没有其他文件后移除空配置目录。卸载器不会自动删除 `tallynote` 系统用户,也不会跟随符号链接删除目录。检测到 `.update-state` 或 `update-request.json` 时会拒绝执行,确认更新已经停止后使用 `--force`。 + ## 后台一键更新 将环境文件中的 `TALLYNOTE_UPDATE_STRATEGY=systemd`、`TALLYNOTE_UPDATE_METADATA_URL` 和 `TALLYNOTE_UPDATE_ALLOWED_HOSTS` 配好后,后台“系统更新”会读取 Gitea 的 `/api/v1/repos///releases/latest`。检查结果只显示当前平台匹配且通过 SHA-256 校验的资产;如果配置了 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 并启用签名要求,再额外验证 Ed25519 签名。 diff --git a/install.sh b/install.sh index 0575c66..eb8621f 100755 --- a/install.sh +++ b/install.sh @@ -373,7 +373,7 @@ normalize_release_tree() { fi find "$root" -type d -exec chmod 755 {} + find "$root" -type f -exec chmod 644 {} + - for item in "$root/bin"/* "$root/scripts"/*.sh "$root/runtime/bin"/*; do + for item in "$root/bin"/* "$root/scripts"/*.sh "$root/runtime/bin"/* "$root/uninstall.sh"; do [[ -f "$item" && ! -L "$item" ]] || continue chmod 755 "$item" done @@ -541,10 +541,11 @@ rollback_install_if_needed() { fi if (( INSTALL_COMMITTED == 0 )) && [[ -n "$INSTALL_BACKUP_DIR" && -d "$INSTALL_BACKUP_DIR" ]]; then local backup_name target - for backup_name in tallynote.service tallynote-update.service tallynote-update.path tallynote.env update-signing-key.pub; do + for backup_name in tallynote.service tallynote-update.service tallynote-update.path tallynote-uninstall tallynote.env update-signing-key.pub; do case "$backup_name" in tallynote.env) target="$CONFIG_DIR/tallynote.env" ;; update-signing-key.pub) target="$CONFIG_DIR/update-signing-key.pub" ;; + tallynote-uninstall) target="/usr/local/sbin/tallynote-uninstall" ;; *) target="/etc/systemd/system/$backup_name" ;; esac [[ ! -L "$target" ]] || continue @@ -586,6 +587,12 @@ backup_install_files() { cp -a -- "$target" "$directory/$name" fi done + target="/usr/local/sbin/tallynote-uninstall" + [[ ! -L "$target" ]] || die "现有卸载器不能是符号链接:$target" + if [[ -e "$target" ]]; then + [[ -f "$target" ]] || die "现有卸载器不是普通文件:$target" + cp -a -- "$target" "$directory/tallynote-uninstall" + fi } read_env_value() { @@ -659,7 +666,7 @@ install_release() { [[ -x "$tmp/unpacked/bin/tallynote" ]] || die 'release archive must contain executable bin/tallynote' [[ -f "$tmp/unpacked/package.json" && -f "$tmp/unpacked/dist/server/index.js" && -f "$tmp/unpacked/dist/web/index.html" ]] || die 'release archive is incomplete' [[ -f "$tmp/unpacked/systemd/tallynote.service" && -f "$tmp/unpacked/systemd/tallynote-update.service" && -f "$tmp/unpacked/systemd/tallynote-update.path" ]] || die 'release archive is missing systemd units' - [[ -f "$tmp/unpacked/systemd/tallynote.env.example" && -x "$tmp/unpacked/scripts/tallynote-update.sh" && -x "$tmp/unpacked/scripts/tallynote-update-runner.sh" ]] || die 'release archive is missing update support files' + [[ -f "$tmp/unpacked/systemd/tallynote.env.example" && -x "$tmp/unpacked/scripts/tallynote-update.sh" && -x "$tmp/unpacked/scripts/tallynote-update-runner.sh" && -x "$tmp/unpacked/uninstall.sh" ]] || die 'release archive is missing update/uninstall support files' grep -Eq '"version"[[:space:]]*:[[:space:]]*"'"$version"'"([,}]|[[:space:]])' "$tmp/unpacked/package.json" || die 'release package version does not match requested version' ensure_root_directory "$PREFIX" 755 ensure_root_directory "$PREFIX/releases" 755 @@ -817,7 +824,7 @@ main() { install_release "$archive" "$VERSION" release_dir="$PREFIX/releases/$VERSION" [[ -f "$release_dir/systemd/tallynote.service" && -f "$release_dir/systemd/tallynote-update.service" && -f "$release_dir/systemd/tallynote-update.path" ]] || die 'release package is missing systemd unit files' - [[ -f "$release_dir/systemd/tallynote.env.example" && -f "$release_dir/scripts/tallynote-update-runner.sh" ]] || die 'release package is missing update support files' + [[ -f "$release_dir/systemd/tallynote.env.example" && -f "$release_dir/scripts/tallynote-update-runner.sh" && -x "$release_dir/uninstall.sh" ]] || die 'release package is missing update/uninstall support files' install -d -m 755 /usr/local/libexec /etc/systemd/system local unit_tmp unit_tmp=$(mktemp -d) @@ -830,6 +837,7 @@ main() { rm -rf "$unit_tmp" install -o root -g root -m 755 "$release_dir/scripts/tallynote-update.sh" /usr/local/sbin/tallynote-update install -o root -g root -m 755 "$release_dir/scripts/tallynote-update-runner.sh" /usr/local/libexec/tallynote-update-runner + install -o root -g root -m 755 "$release_dir/uninstall.sh" /usr/local/sbin/tallynote-uninstall ensure_root_directory "$(dirname -- "$DATA_DIR")/tallynote-backups" 700 if [[ ! -f "$CONFIG_DIR/tallynote.env" ]]; then sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote.env.example" > "$CONFIG_DIR/tallynote.env" diff --git a/package.json b/package.json index 56a3a40..f1a1723 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tallynote", - "version": "1.1.1", + "version": "1.1.2", "private": true, "type": "module", "packageManager": "pnpm@9.0.6", @@ -20,6 +20,7 @@ "check": "tsc -p tsconfig.server.json --noEmit && tsc -p tsconfig.web-next.json --noEmit", "check:next": "tsc -p tsconfig.web-next.json --noEmit", "test": "vitest run", + "test:installer": "bash scripts/test-installer.sh && bash scripts/test-uninstaller.sh", "test:watch": "vitest", "test:e2e": "playwright test" }, diff --git a/scripts/build-release.sh b/scripts/build-release.sh index cc81e30..583ea03 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -32,10 +32,11 @@ cp -a migrations/. "$stage/migrations/" cp package.json pnpm-lock.yaml "$stage/" cp -a bin/. "$stage/bin/" cp -a scripts/tallynote-update.sh scripts/tallynote-update-runner.sh "$stage/scripts/" +cp uninstall.sh "$stage/uninstall.sh" cp -a systemd/tallynote.service systemd/tallynote-update.service systemd/tallynote-update.path systemd/tallynote.env.example "$stage/systemd/" node_path=$(command -v node) cp -L "$node_path" "$stage/runtime/bin/node" -chmod 755 "$stage/bin/tallynote" "$stage/scripts"/*.sh "$stage/runtime/bin/node" +chmod 755 "$stage/bin/tallynote" "$stage/scripts"/*.sh "$stage/runtime/bin/node" "$stage/uninstall.sh" # pnpm's default linker creates symlinks. A release archive is deliberately # symlink-free so the installer can reject traversal links deterministically. diff --git a/scripts/test-installer.sh b/scripts/test-installer.sh index b6f7a9a..eecad6b 100755 --- a/scripts/test-installer.sh +++ b/scripts/test-installer.sh @@ -82,10 +82,12 @@ bash -c ' source_tmp="$tmp/source" mkdir -p "$source_tmp/dist/server" "$source_tmp/bin" "$source_tmp/scripts" "$source_tmp/runtime/bin" printf '%s\n' 'server' > "$source_tmp/dist/server/index.js" +printf '%s\n' '#!/bin/sh' > "$source_tmp/uninstall.sh" printf '%s\n' '#!/bin/sh' > "$source_tmp/bin/tallynote" printf '%s\n' '#!/bin/sh' > "$source_tmp/scripts/runner.sh" printf '%s\n' 'node' > "$source_tmp/runtime/bin/node" chmod 755 "$source_tmp/bin/tallynote" "$source_tmp/scripts/runner.sh" "$source_tmp/runtime/bin/node" +chmod 755 "$source_tmp/uninstall.sh" archive_tmp="$tmp/release.tar.gz" tar -C "$source_tmp" -czf "$archive_tmp" . bash -c ' @@ -99,6 +101,7 @@ bash -c ' [[ "$(stat_mode "$destination/dist")" == 755 ]] [[ "$(stat_mode "$destination/dist/server/index.js")" == 644 ]] [[ "$(stat_mode "$destination/bin/tallynote")" == 755 ]] + [[ "$(stat_mode "$destination/uninstall.sh")" == 755 ]] ' _ "$installer_lib" "$archive_tmp" "$tmp/unpacked" # A normal public-release install only needs the detached SHA-256 manifest; diff --git a/scripts/test-uninstaller.sh b/scripts/test-uninstaller.sh new file mode 100755 index 0000000..de5e38a --- /dev/null +++ b/scripts/test-uninstaller.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +root=$(cd -- "$(dirname -- "$0")/.." && pwd -P) +bash -n "$root/uninstall.sh" + +tmp=$(cd "$(mktemp -d)" && pwd -P) +cleanup() { rm -rf -- "$tmp" 2>/dev/null || true; } +trap cleanup EXIT + +make_fixture() { + local fixture=$1 + mkdir -p "$fixture/opt/tallynote/releases/1.1.1/dist" \ + "$fixture/opt/tallynote/.update-work" \ + "$fixture/var/lib/tallynote/files" \ + "$fixture/var/lib/tallynote/staging" \ + "$fixture/var/lib/tallynote/exports" \ + "$fixture/var/lib/tallynote-backups" \ + "$fixture/etc/tallynote" \ + "$fixture/etc/systemd/system" \ + "$fixture/usr/local/sbin" \ + "$fixture/usr/local/libexec" + printf '%s\n' 'release' > "$fixture/opt/tallynote/releases/1.1.1/dist/index.js" + ln -s "$fixture/opt/tallynote/releases/1.1.1" "$fixture/opt/tallynote/current" + printf '%s\n' \ + "TALLYNOTE_INSTALL_PREFIX=$fixture/opt/tallynote" \ + "TALLYNOTE_DATA_DIR=$fixture/var/lib/tallynote" \ + "TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=$fixture/etc/tallynote/update-signing-key.pub" \ + > "$fixture/etc/tallynote/tallynote.env" + chmod 600 "$fixture/etc/tallynote/tallynote.env" + printf '%s\n' 'fake public key' > "$fixture/etc/tallynote/update-signing-key.pub" + for unit in tallynote.service tallynote-update.service tallynote-update.path; do + printf '%s\n' "Description=TallyNote $unit" "WorkingDirectory=$fixture/opt/tallynote/current" "PathExists=$fixture/var/lib/tallynote/update-request.json" > "$fixture/etc/systemd/system/$unit" + done + printf '%s\n' '#!/usr/bin/env bash' 'PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote}' 'echo TallyNote' > "$fixture/usr/local/sbin/tallynote-update" + printf '%s\n' '#!/usr/bin/env bash' 'PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote}' 'echo TallyNote' > "$fixture/usr/local/libexec/tallynote-update-runner" + cp "$root/uninstall.sh" "$fixture/usr/local/sbin/tallynote-uninstall" + chmod 755 "$fixture/usr/local/sbin/tallynote-update" "$fixture/usr/local/libexec/tallynote-update-runner" "$fixture/usr/local/sbin/tallynote-uninstall" + printf '%s\n' 'sqlite' > "$fixture/var/lib/tallynote/tallynote.db" + printf '%s\n' 'backup' > "$fixture/var/lib/tallynote-backups/backup.db" +} + +make_systemctl() { + local fixture=$1 + cat > "$fixture/systemctl" <<'EOF' +#!/usr/bin/env bash +set -u +printf '%s\n' "$*" >> "$TALLYNOTE_TEST_SYSTEMCTL_LOG" +case "${1:-}" in + is-active) exit 0 ;; + stop|disable|daemon-reload) exit 0 ;; + *) exit 0 ;; +esac +EOF + chmod 755 "$fixture/systemctl" +} + +run_uninstall() { + local fixture=$1 + TALLYNOTE_UNINSTALL_TEST_MODE=true \ + TALLYNOTE_UNINSTALL_ROOT="$fixture" \ + TALLYNOTE_SYSTEMCTL_BIN="$fixture/systemctl" \ + TALLYNOTE_TEST_SYSTEMCTL_LOG="$fixture/systemctl.log" \ + bash "$root/uninstall.sh" "${@:2}" +} + +fixture="$tmp/normal" +make_fixture "$fixture" +make_systemctl "$fixture" +run_uninstall "$fixture" +[[ -d "$fixture/var/lib/tallynote" && -f "$fixture/var/lib/tallynote/tallynote.db" ]] +[[ -d "$fixture/var/lib/tallynote-backups" && -f "$fixture/var/lib/tallynote-backups/backup.db" ]] +[[ ! -e "$fixture/opt/tallynote" || -z "$(find "$fixture/opt/tallynote" -mindepth 1 -print -quit 2>/dev/null)" ]] +[[ ! -e "$fixture/etc/systemd/system/tallynote.service" ]] +[[ ! -e "$fixture/usr/local/sbin/tallynote-update" ]] +grep -n '^is-active.*tallynote-update.path' "$fixture/systemctl.log" >/dev/null +grep -n '^stop tallynote-update.path' "$fixture/systemctl.log" >/dev/null +path_stop=$(grep -n '^stop tallynote-update.path' "$fixture/systemctl.log" | head -n1 | cut -d: -f1) +update_stop=$(grep -n '^stop tallynote-update.service' "$fixture/systemctl.log" | head -n1 | cut -d: -f1) +main_stop=$(grep -n '^stop tallynote.service' "$fixture/systemctl.log" | head -n1 | cut -d: -f1) +(( path_stop < update_stop && update_stop < main_stop )) + +# Re-running after the first uninstall is harmless and does not touch data. +run_uninstall "$fixture" +[[ -f "$fixture/var/lib/tallynote/tallynote.db" ]] + +# Purge requires the explicit acknowledgement flag and must fail before any +# application files are removed. +fixture="$tmp/purge" +make_fixture "$fixture" +make_systemctl "$fixture" +if run_uninstall "$fixture" --purge-data >/dev/null 2>&1; then + echo 'expected --purge-data without --yes to fail' >&2 + exit 1 +fi +[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]] +run_uninstall "$fixture" --purge-data --yes --purge-config +[[ ! -e "$fixture/var/lib/tallynote" && ! -e "$fixture/var/lib/tallynote-backups" ]] +[[ ! -e "$fixture/etc/tallynote" ]] + +# A custom data path must not overlap the release prefix; otherwise removing +# releases could destroy data that the default uninstall promises to keep. +fixture="$tmp/overlap" +make_fixture "$fixture" +make_systemctl "$fixture" +printf '%s\n' \ + "TALLYNOTE_INSTALL_PREFIX=$fixture/opt/tallynote" \ + "TALLYNOTE_DATA_DIR=$fixture/opt/tallynote/releases/data" \ + > "$fixture/etc/tallynote/tallynote.env" +mkdir -p "$fixture/opt/tallynote/releases/data" +printf '%s\n' protected > "$fixture/opt/tallynote/releases/data/keep.db" +if run_uninstall "$fixture" >/dev/null 2>&1; then + echo 'expected overlapping data path to fail' >&2 + exit 1 +fi +[[ -f "$fixture/opt/tallynote/releases/data/keep.db" ]] + +# Trailing-slash aliases are rejected before the lexical overlap guard can be +# bypassed. +fixture="$tmp/trailing" +make_fixture "$fixture" +make_systemctl "$fixture" +printf '%s\n' \ + "TALLYNOTE_INSTALL_PREFIX=$fixture/opt/tallynote/" \ + "TALLYNOTE_DATA_DIR=$fixture/var/lib/tallynote" \ + > "$fixture/etc/tallynote/tallynote.env" +if run_uninstall "$fixture" >/dev/null 2>&1; then + echo 'expected trailing slash path to fail' >&2 + exit 1 +fi +[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]] + +# Dot-component aliases are rejected as well; textual paths must be canonical +# before the managed-directory containment checks run. +fixture="$tmp/dot" +make_fixture "$fixture" +make_systemctl "$fixture" +printf '%s\n' \ + "TALLYNOTE_INSTALL_PREFIX=$fixture/opt/tallynote/." \ + "TALLYNOTE_DATA_DIR=$fixture/var/lib/tallynote" \ + > "$fixture/etc/tallynote/tallynote.env" +if run_uninstall "$fixture" >/dev/null 2>&1; then + echo 'expected dot path component to fail' >&2 + exit 1 +fi +[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]] + +# Pending update state blocks destructive work until an operator overrides it. +fixture="$tmp/pending" +make_fixture "$fixture" +make_systemctl "$fixture" +printf '%s\n' pending > "$fixture/opt/tallynote/.update-state" +if run_uninstall "$fixture" >/dev/null 2>&1; then + echo 'expected pending update state to block uninstall' >&2 + exit 1 +fi +[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]] + +# A current link escaping the release tree is rejected without deleting data. +fixture="$tmp/link" +make_fixture "$fixture" +make_systemctl "$fixture" +rm -f "$fixture/opt/tallynote/current" +ln -s "$fixture/outside" "$fixture/opt/tallynote/current" +if run_uninstall "$fixture" >/dev/null 2>&1; then + echo 'expected unsafe current symlink to fail' >&2 + exit 1 +fi +[[ -L "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]] + +# dry-run must not call systemctl or remove files. +fixture="$tmp/dry-run" +make_fixture "$fixture" +make_systemctl "$fixture" +run_uninstall "$fixture" --dry-run >/dev/null +[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]] +[[ ! -e "$fixture/systemctl.log" ]] + +printf '%s\n' 'uninstaller shell tests passed' diff --git a/tests/update-api.test.ts b/tests/update-api.test.ts index 14be65e..5259f18 100644 --- a/tests/update-api.test.ts +++ b/tests/update-api.test.ts @@ -59,10 +59,10 @@ describe("更新 API", () => { function mockRelease() { const digest = "c".repeat(64); - const asset = `tallynote-1.1.2-${detectPlatform().target}-glibc.tar.gz`; + const asset = `tallynote-1.1.3-${detectPlatform().target}-glibc.tar.gz`; globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS") ? new Response(`${digest} ${asset}\n`, { status: 200 }) - : new Response(JSON.stringify({ tag_name: "v1.1.2", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: asset, browser_download_url: `https://updates.example/${asset}` }] }), { status: 200 })) as typeof fetch; + : new Response(JSON.stringify({ tag_name: "v1.1.3", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: asset, browser_download_url: `https://updates.example/${asset}` }] }), { status: 200 })) as typeof fetch; } it("检查 release、创建受保护请求文件并拒绝重复任务", async () => { @@ -70,21 +70,21 @@ describe("更新 API", () => { mockRelease(); const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} }); expect(checked.statusCode).toBe(200); - expect(checked.json().latest).toMatchObject({ version: "1.1.2", compatible: true, integrityReady: true, isNewer: true }); + expect(checked.json().latest).toMatchObject({ version: "1.1.3", compatible: true, integrityReady: true, isNewer: true }); expect(checked.headers["cache-control"]).toBe("no-store"); const tooSoon = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} }); expect(tooSoon.statusCode).toBe(429); expect(tooSoon.headers["retry-after"]).toBeDefined(); - const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.2", confirm: true } }); + const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.3", confirm: true } }); expect(applied.statusCode).toBe(202); const jobId = applied.json().job.id as string; const request = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; expectedSha256: string; currentLink: string }; - expect(request).toMatchObject({ jobId, version: "1.1.2", expectedSha256: "c".repeat(64), currentLink: config.currentLink }); + expect(request).toMatchObject({ jobId, version: "1.1.3", expectedSha256: "c".repeat(64), currentLink: config.currentLink }); expect(statSync(config.updateRequestPath).mode & 0o777).toBe(0o600); mockRelease(); - const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.2", confirm: true } }); + const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.3", confirm: true } }); expect(duplicate.statusCode).toBe(409); expect(duplicate.json().error.code).toBe("UPDATE_IN_PROGRESS"); const status = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } }); @@ -95,7 +95,7 @@ describe("更新 API", () => { it("缺少确认或未启用 systemd 时不接受更新", async () => { const session = await login(); - const invalid = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.2" } }); + const invalid = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.3" } }); expect(invalid.statusCode).toBe(400); process.env.TALLYNOTE_UPDATE_STRATEGY = "disabled"; const disabledConfig = loadConfig(); @@ -108,7 +108,7 @@ describe("更新 API", () => { mockRelease(); const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: {} }); expect(checked.statusCode).toBe(200); - const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: { version: "1.1.2", confirm: true } }); + const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: { version: "1.1.3", confirm: true } }); expect(applied.statusCode).toBe(202); const jobId = applied.json().job.id as string; database.sqlite.prepare("UPDATE update_jobs SET error_message=? WHERE id=?").run("/var/lib/tallynote/secret-command-output", jobId); @@ -126,7 +126,7 @@ describe("更新 API", () => { it("应用前重新校验失败时写入失败审计", async () => { const session = await login("update-audit"); globalThis.fetch = (async () => new Response("upstream unavailable", { status: 503 })) as typeof fetch; - const response = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.2", confirm: true } }); + const response = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.3", confirm: true } }); expect(response.statusCode).toBe(502); const audit = database.sqlite.prepare("SELECT outcome FROM audit_events WHERE action='update.apply_requested' ORDER BY id DESC LIMIT 1").get() as { outcome: string } | undefined; expect(audit?.outcome).toBe("failure"); diff --git a/tests/update.test.ts b/tests/update.test.ts index e1ec2cd..2f887f6 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -273,17 +273,17 @@ describe("更新元数据缓存", () => { prepareDataDirectories(config); const database = openDatabase(config); const digest = "b".repeat(64); - const platformAsset = `tallynote-1.1.2-${detectPlatform().target}-glibc.tar.gz`; + const platformAsset = `tallynote-1.1.3-${detectPlatform().target}-glibc.tar.gz`; const sums = `${digest} ${platformAsset}\n`; const signature = sign(null, Buffer.from(sums), privateKey); globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS.sig") ? new Response(signature) : input.toString().endsWith("SHA256SUMS") ? new Response(sums) - : new Response(JSON.stringify({ tag_name: "v1.1.2", published_at: "2026-08-28T00:00:00Z", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: platformAsset, browser_download_url: `https://updates.example/${platformAsset}` }] }), { status: 200 })) as typeof fetch; + : new Response(JSON.stringify({ tag_name: "v1.1.3", published_at: "2026-08-28T00:00:00Z", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: platformAsset, browser_download_url: `https://updates.example/${platformAsset}` }] }), { status: 200 })) as typeof fetch; try { const result = await checkForUpdate(database.sqlite, config); - expect(result.latest).toMatchObject({ version: "1.1.2", compatible: true, integrityReady: true, signatureReady: true, isNewer: true }); + expect(result.latest).toMatchObject({ version: "1.1.3", compatible: true, integrityReady: true, signatureReady: true, isNewer: true }); const cached = database.sqlite.prepare("SELECT value FROM system_settings WHERE key='update.release.v1'").get() as { value: string }; expect(JSON.parse(cached.value).asset.sha256).toBe(digest); } finally { diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..1e33cab --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,473 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# TallyNote native uninstaller. The default operation removes only the +# application and service integration; the database and attachments stay in +# place until --purge-data --yes is explicitly requested. +PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin +export PATH +umask 077 + +TEST_MODE=${TALLYNOTE_UNINSTALL_TEST_MODE:-false} +TEST_ROOT=${TALLYNOTE_UNINSTALL_ROOT:-} +PREFIX=${TALLYNOTE_PREFIX:-/opt/tallynote} +DATA_DIR=${TALLYNOTE_DATA_DIR:-/var/lib/tallynote} +CONFIG_DIR=${TALLYNOTE_CONFIG_DIR:-/etc/tallynote} +UNIT_DIR=${TALLYNOTE_SYSTEMD_UNIT_DIR:-/etc/systemd/system} +SBIN_DIR=${TALLYNOTE_SBIN_DIR:-/usr/local/sbin} +LIBEXEC_DIR=${TALLYNOTE_LIBEXEC_DIR:-/usr/local/libexec} +SYSTEMCTL_BIN=systemctl +SYSTEMCTL_AVAILABLE=0 +PURGE_DATA=0 +PURGE_CONFIG=0 +YES=0 +DRY_RUN=0 +FORCE=0 +EXPLICIT_PREFIX=0 +EXPLICIT_DATA=0 +EXPLICIT_CONFIG=0 + +die() { printf 'tallynote uninstaller: %s\n' "$*" >&2; exit 1; } +log() { printf 'tallynote uninstaller: %s\n' "$*"; } + +usage() { + cat <<'EOF' +Usage: tallynote-uninstall [--yes] [--purge-data] [--purge-config] + [--dry-run] [--force] + [--prefix PATH] [--data-dir PATH] [--config-dir PATH] + +By default, remove the TallyNote release tree, systemd units, update helpers, +and known configuration files. The database, attachments, staging, exports, +update queue, and update backups are preserved. Data removal requires both +--purge-data and --yes. --force is only for an operator who has verified that +no update is in progress; it overrides the pending-update guard. +EOF +} + +is_true() { [[ "$1" == true || "$1" == 1 ]]; } + +if [[ "$TEST_MODE" != true && "$TEST_MODE" != false && "$TEST_MODE" != 1 && "$TEST_MODE" != 0 ]]; then + die 'TALLYNOTE_UNINSTALL_TEST_MODE must be true or false' +fi +if [[ "$TEST_MODE" == 1 ]]; then TEST_MODE=true; fi +if [[ "$TEST_MODE" == 0 ]]; then TEST_MODE=false; fi + +while (($#)); do + case "$1" in + --yes) YES=1 ;; + --purge-data) PURGE_DATA=1 ;; + --purge-config) PURGE_CONFIG=1 ;; + --dry-run) DRY_RUN=1 ;; + --force) FORCE=1 ;; + --prefix) PREFIX=${2:?missing value for --prefix}; EXPLICIT_PREFIX=1; shift ;; + --data-dir) DATA_DIR=${2:?missing value for --data-dir}; EXPLICIT_DATA=1; shift ;; + --config-dir) CONFIG_DIR=${2:?missing value for --config-dir}; EXPLICIT_CONFIG=1; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac + shift +done + +if [[ "$TEST_MODE" == true ]]; then + [[ -n "$TEST_ROOT" ]] || die 'test mode requires TALLYNOTE_UNINSTALL_ROOT' + [[ "$TEST_ROOT" = /* && "$TEST_ROOT" != *'..'* && "$TEST_ROOT" != *'//'* && "$TEST_ROOT" != *$'\n'* && "$TEST_ROOT" != *$'\r'* ]] || die 'test root is invalid' + (( EXPLICIT_PREFIX )) || PREFIX=${TALLYNOTE_PREFIX:-$TEST_ROOT/opt/tallynote} + (( EXPLICIT_DATA )) || DATA_DIR=${TALLYNOTE_DATA_DIR:-$TEST_ROOT/var/lib/tallynote} + (( EXPLICIT_CONFIG )) || CONFIG_DIR=${TALLYNOTE_CONFIG_DIR:-$TEST_ROOT/etc/tallynote} + UNIT_DIR=${TALLYNOTE_SYSTEMD_UNIT_DIR:-$TEST_ROOT/etc/systemd/system} + SBIN_DIR=${TALLYNOTE_SBIN_DIR:-$TEST_ROOT/usr/local/sbin} + LIBEXEC_DIR=${TALLYNOTE_LIBEXEC_DIR:-$TEST_ROOT/usr/local/libexec} + SYSTEMCTL_BIN=${TALLYNOTE_SYSTEMCTL_BIN:-systemctl} +fi + +stat_uid() { stat -c '%u' "$1" 2>/dev/null || stat -f '%u' "$1"; } +stat_mode() { stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1"; } +stat_mode_bits() { + local mode + mode=$(stat_mode "$1") + [[ "$mode" =~ ^[0-7]+$ ]] || die "无法读取路径权限:$1" + printf '%d' "$((8#$mode))" +} + +allowed_owner() { + local path=$1 uid + uid=$(stat_uid "$path") + if [[ "$TEST_MODE" == true ]]; then + [[ "$uid" == "$(id -u)" || "$uid" == 0 ]] + else + [[ "$uid" == 0 ]] + fi +} + +allowed_data_owner() { + local path=$1 uid tallynote_uid + uid=$(stat_uid "$path") + if [[ "$TEST_MODE" == true ]]; then + [[ "$uid" == "$(id -u)" || "$uid" == 0 ]] + return + fi + [[ "$uid" == 0 ]] && return 0 + tallynote_uid=$(id -u tallynote 2>/dev/null || true) + [[ -n "$tallynote_uid" && "$uid" == "$tallynote_uid" ]] +} + +validate_path_value() { + local value=$1 label=$2 + [[ "$value" = /* && "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "$label 必须是绝对路径" + [[ "$value" =~ ^/[A-Za-z0-9._/-]+$ && "$value" != *"//"* && "$value" != *"/../"* && "$value" != */.. && "$value" != *"/./"* && "$value" != */. && "$value" != / && "$value" != */ ]] || die "$label 包含不受支持的路径字符" + case "$value" in + /opt|/var|/etc|/usr|/usr/local|/bin|/sbin|/home|/root|/tmp) die "$label 不能指向系统顶层目录" ;; + esac +} + +validate_parent_chain() { + local target=$1 current=/ component relative + relative=${target#/} + IFS='/' read -r -a _parts <<< "$relative" + for component in "${_parts[@]}"; do + [[ -n "$component" ]] || continue + current="${current%/}/$component" + if [[ -L "$current" ]]; then die "路径不能包含符号链接:$current"; fi + if [[ -e "$current" ]]; then + [[ -d "$current" ]] || die "路径不是目录:$current" + allowed_owner "$current" || die "路径目录的所有者不受信任:$current" + local mode_bits + mode_bits=$(stat_mode_bits "$current") + (( (mode_bits & 18) == 0 || (mode_bits & 512) != 0 )) || die "路径目录权限过宽:$current" + fi + done +} + +validate_target() { + local target=$1 label=$2 owner_check=allowed_owner + [[ "${3:-}" == data ]] && owner_check=allowed_data_owner + validate_path_value "$target" "$label" + validate_parent_chain "$target" + if [[ -e "$target" || -L "$target" ]]; then + "$owner_check" "$target" || die "$label 的所有者不受信任:$target" + fi +} + +read_env_value() { + local file=$1 key=$2 + sed -n "s/^${key}=//p" "$file" | head -n 1 +} + +env_key_count() { + local file=$1 key=$2 + awk -v key="$key" 'index($0, key "=") == 1 { count += 1 } END { print count + 0 }' "$file" +} + +load_config() { + local env_file=$CONFIG_DIR/tallynote.env value key count + [[ -e "$env_file" || -L "$env_file" ]] || return 0 + [[ -f "$env_file" && ! -L "$env_file" ]] || die '环境文件不是普通文件' + allowed_owner "$env_file" || die '环境文件的所有者不受信任' + local mode_bits + mode_bits=$(stat_mode_bits "$env_file") + (( (mode_bits & 18) == 0 )) || die '环境文件不能被组或其他用户写入' + for key in TALLYNOTE_INSTALL_PREFIX TALLYNOTE_DATA_DIR; do + count=$(env_key_count "$env_file" "$key") + [[ "$count" == 0 || "$count" == 1 ]] || die "环境文件包含重复配置:$key" + done + if (( ! EXPLICIT_PREFIX )); then + value=$(read_env_value "$env_file" TALLYNOTE_INSTALL_PREFIX) + [[ -z "$value" ]] || PREFIX=$value + fi + if (( ! EXPLICIT_DATA )); then + value=$(read_env_value "$env_file" TALLYNOTE_DATA_DIR) + [[ -z "$value" ]] || DATA_DIR=$value + fi +} + +path_inside() { + local child=$1 parent=$2 + [[ "$child" == "$parent"/* ]] +} + +assert_disjoint_paths() { + local left left_label right right_label + local -a labels=(prefix data config unit sbin libexec) + for left_label in "${labels[@]}"; do + case "$left_label" in + prefix) left=$PREFIX ;; + data) left=$DATA_DIR ;; + config) left=$CONFIG_DIR ;; + unit) left=$UNIT_DIR ;; + sbin) left=$SBIN_DIR ;; + libexec) left=$LIBEXEC_DIR ;; + esac + for right_label in "${labels[@]}"; do + [[ "$left_label" == "$right_label" ]] && continue + case "$right_label" in + prefix) right=$PREFIX ;; + data) right=$DATA_DIR ;; + config) right=$CONFIG_DIR ;; + unit) right=$UNIT_DIR ;; + sbin) right=$SBIN_DIR ;; + libexec) right=$LIBEXEC_DIR ;; + esac + if [[ "$left" == "$right" ]] || path_inside "$left" "$right" || path_inside "$right" "$left"; then + die "卸载目录不能互相嵌套:$left 与 $right" + fi + done + done +} + +assert_test_scope() { + [[ "$TEST_MODE" == true ]] || return 0 + [[ -d "$TEST_ROOT" && ! -L "$TEST_ROOT" ]] || die 'test root must be an existing directory' + validate_parent_chain "$TEST_ROOT" + allowed_owner "$TEST_ROOT" || die 'test root owner is not trusted' + local value label + for label in PREFIX DATA_DIR CONFIG_DIR UNIT_DIR SBIN_DIR LIBEXEC_DIR; do + case "$label" in + PREFIX) value=$PREFIX ;; + DATA_DIR) value=$DATA_DIR ;; + CONFIG_DIR) value=$CONFIG_DIR ;; + UNIT_DIR) value=$UNIT_DIR ;; + SBIN_DIR) value=$SBIN_DIR ;; + LIBEXEC_DIR) value=$LIBEXEC_DIR ;; + esac + [[ "$value" == "$TEST_ROOT"/* ]] || die "test mode path escapes TALLYNOTE_UNINSTALL_ROOT: $value" + done +} + +managed_file() { + local target=$1 label=$2 + case "$label" in + service\ unit|updater\ unit|path\ unit|update\ helper|update\ runner|uninstaller) + grep -Eiq 'tallynote|TallyNote' "$target" || return 1 + if [[ "$label" == 'path unit' ]]; then + grep -Fq "$DATA_DIR" "$target" || return 1 + elif [[ "$label" == *unit ]]; then + grep -Fq "$PREFIX" "$target" || return 1 + else + grep -Eq 'TALLYNOTE_INSTALL_PREFIX|/opt/tallynote' "$target" || return 1 + fi + ;; + environment\ file) + grep -q '^TALLYNOTE_INSTALL_PREFIX=' "$target" || return 1 + grep -q '^TALLYNOTE_DATA_DIR=' "$target" || return 1 + [[ "$(read_env_value "$target" TALLYNOTE_INSTALL_PREFIX)" == "$PREFIX" ]] || return 1 + [[ "$(read_env_value "$target" TALLYNOTE_DATA_DIR)" == "$DATA_DIR" ]] || return 1 + ;; + update\ public\ key) + [[ -f "$CONFIG_DIR/tallynote.env" ]] || return 1 + [[ "$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_UPDATE_PUBLIC_KEY_FILE)" == "$target" ]] || return 1 + ;; + *) return 0 ;; + esac +} + +validate_release_tree() { + local tree=$1 owner_check=${2:-allowed_owner} + [[ -d "$tree" && ! -L "$tree" ]] || die "发布目录无效:$tree" + "$owner_check" "$tree" || die "发布目录的所有者不受信任:$tree" + if find "$tree" -type l -print -quit | grep -q .; then + die "发布目录包含符号链接:$tree" + fi + if find "$tree" ! -type d ! -type f -print -quit | grep -q .; then + die "发布目录包含不支持的文件类型:$tree" + fi + local node mode_bits + while IFS= read -r node; do + "$owner_check" "$node" || die "发布目录节点的所有者不受信任:$node" + mode_bits=$(stat_mode_bits "$node") + (( (mode_bits & 18) == 0 )) || die "发布目录节点权限过宽:$node" + done < <(find "$tree" -print) +} + +pending_update() { + [[ -e "$PREFIX/.update-state" || -L "$PREFIX/.update-state" || -e "$DATA_DIR/update-request.json" || -L "$DATA_DIR/update-request.json" ]] +} + +run_systemctl() { + (( DRY_RUN )) && return 0 + if [[ "$SYSTEMCTL_BIN" == */* ]]; then + [[ -x "$SYSTEMCTL_BIN" ]] || return 0 + else + command -v "$SYSTEMCTL_BIN" >/dev/null 2>&1 || return 0 + fi + "$SYSTEMCTL_BIN" "$@" +} + +stop_services() { + local unit active status + if (( DRY_RUN )); then + log 'dry-run: would stop/disable systemd units in path -> updater -> app order' + return 0 + fi + if (( ! SYSTEMCTL_AVAILABLE )); then + for unit in tallynote-update.path tallynote-update.service tallynote.service; do + [[ ! -e "$UNIT_DIR/$unit" ]] || die 'systemctl 不可用,无法安全停止已安装服务' + done + return 0 + fi + for unit in tallynote-update.path tallynote-update.service tallynote.service; do + active=0 + if run_systemctl is-active --quiet "$unit" >/dev/null 2>&1; then + active=1 + else + status=$? + case "$status" in + 3|4) ;; + *) die "无法读取服务状态:$unit" ;; + esac + fi + if (( active )); then + run_systemctl stop "$unit" || die "无法停止服务:$unit" + fi + if [[ -e "$UNIT_DIR/$unit" ]]; then + run_systemctl disable "$unit" >/dev/null 2>&1 || die "无法禁用服务:$unit" + fi + done + run_systemctl daemon-reload >/dev/null 2>&1 || die 'systemd daemon-reload 失败' +} + +validate_systemctl() { + local resolved uid mode_bits + if [[ "$TEST_MODE" == true ]]; then + if [[ "$SYSTEMCTL_BIN" == */* && -x "$SYSTEMCTL_BIN" ]]; then + SYSTEMCTL_AVAILABLE=1 + fi + return 0 + fi + resolved=$(command -v systemctl 2>/dev/null || true) + if [[ -z "$resolved" ]]; then + SYSTEMCTL_AVAILABLE=0 + return 0 + fi + [[ -x "$resolved" && ! -L "$resolved" ]] || die 'systemctl 必须是可信的普通可执行文件' + uid=$(stat_uid "$resolved") + mode_bits=$(stat_mode_bits "$resolved") + [[ "$uid" == 0 && $((mode_bits & 18)) -eq 0 ]] || die 'systemctl 必须由 root 拥有且不可被其他用户写入' + SYSTEMCTL_BIN=$resolved + SYSTEMCTL_AVAILABLE=1 +} + +remove_file_if_owned() { + local target=$1 label=$2 + [[ -e "$target" || -L "$target" ]] || return 0 + if [[ -L "$target" || ! -f "$target" ]]; then + log "warning: 保留非普通文件:$target" + return 0 + fi + if ! allowed_owner "$target"; then + log "warning: 保留非本安装创建的文件:$target" + return 0 + fi + if ! managed_file "$target" "$label"; then + log "warning: 保留内容不匹配的文件:$target" + return 0 + fi + if (( DRY_RUN )); then + log "dry-run: remove $label $target" + else + rm -f -- "$target" + fi +} + +remove_tree() { + local target=$1 label=$2 owner_check=${3:-allowed_owner} + [[ -e "$target" || -L "$target" ]] || return 0 + [[ -d "$target" && ! -L "$target" ]] || die "$label 不是安全目录:$target" + "$owner_check" "$target" || die "$label 的所有者不受信任:$target" + validate_release_tree "$target" "$owner_check" + if (( DRY_RUN )); then + log "dry-run: remove $label $target" + else + rm -rf -- "$target" + fi +} + +remove_prefix() { + local current=$PREFIX/current current_target releases=$PREFIX/releases + if [[ -L "$current" ]]; then + current_target=$(readlink "$current") + [[ "$current_target" = "$PREFIX/releases/"* && "$current_target" != *'..'* ]] || die 'current 符号链接指向安装目录之外' + [[ -d "$current_target" && ! -L "$current_target" ]] || die 'current 目标不是安全目录' + if (( DRY_RUN )); then + log "dry-run: remove current link $current" + else + rm -f -- "$current" + fi + elif [[ -e "$current" ]]; then + log "warning: 保留非符号链接 current:$current" + fi + remove_tree "$releases" 'releases' + remove_tree "$PREFIX/.update-work" 'update work' + remove_file_if_owned "$PREFIX/.update-state" 'update state' + if [[ -d "$PREFIX" && ! -L "$PREFIX" ]]; then + allowed_owner "$PREFIX" || die "安装目录的所有者不受信任:$PREFIX" + if (( DRY_RUN )); then + log "dry-run: remove empty install directory if empty: $PREFIX" + else + rmdir -- "$PREFIX" 2>/dev/null || true + fi + fi +} + +remove_config() { + remove_file_if_owned "$CONFIG_DIR/update-signing-key.pub" 'update public key' + remove_file_if_owned "$CONFIG_DIR/tallynote.env" 'environment file' + if (( PURGE_CONFIG )) && [[ -d "$CONFIG_DIR" && ! -L "$CONFIG_DIR" ]]; then + allowed_owner "$CONFIG_DIR" || die '配置目录的所有者不受信任' + if (( DRY_RUN )); then log "dry-run: remove config directory if safe: $CONFIG_DIR"; else rmdir -- "$CONFIG_DIR" 2>/dev/null || true; fi + fi +} + +remove_data() { + local backup_dir + backup_dir=$(dirname -- "$DATA_DIR")/tallynote-backups + if (( PURGE_DATA )); then + (( YES )) || die '--purge-data 必须同时提供 --yes' + remove_tree "$DATA_DIR" 'data' allowed_data_owner + remove_tree "$backup_dir" 'backup data' + else + log "保留数据目录:$DATA_DIR" + if [[ -d "$backup_dir" ]]; then + log "保留备份目录:$backup_dir" + fi + fi + return 0 +} + +main() { + if [[ "$TEST_MODE" != true ]]; then + [[ $EUID -eq 0 ]] || die '卸载必须以 root 运行(请使用 sudo)' + fi + if (( PURGE_DATA && ! YES )); then + die '--purge-data 必须同时提供 --yes' + fi + validate_path_value "$CONFIG_DIR" '配置目录' + validate_target "$CONFIG_DIR" '配置目录' + assert_test_scope + load_config + validate_target "$PREFIX" '安装目录' + validate_target "$DATA_DIR" '数据目录' data + validate_target "$CONFIG_DIR" '配置目录' + validate_target "$UNIT_DIR" 'systemd 单元目录' + validate_target "$SBIN_DIR" 'sbin 目录' + validate_target "$LIBEXEC_DIR" 'libexec 目录' + assert_test_scope + assert_disjoint_paths + validate_systemctl + if (( ! FORCE )) && pending_update; then + die '检测到未完成的更新状态;确认更新已停止后使用 --force 重试' + fi + log "target: prefix=$PREFIX data=$DATA_DIR config=$CONFIG_DIR" + stop_services + remove_prefix + remove_file_if_owned "$UNIT_DIR/tallynote.service" 'service unit' + remove_file_if_owned "$UNIT_DIR/tallynote-update.service" 'updater unit' + remove_file_if_owned "$UNIT_DIR/tallynote-update.path" 'path unit' + remove_file_if_owned "$SBIN_DIR/tallynote-update" 'update helper' + remove_file_if_owned "$LIBEXEC_DIR/tallynote-update-runner" 'update runner' + remove_file_if_owned "$SBIN_DIR/tallynote-uninstall" 'uninstaller' + remove_config + remove_data + log 'uninstall complete' +} + +main "$@"