From 5247645777b33a0c4e20a1bc464e20a79afdb754 Mon Sep 17 00:00:00 2001 From: "XXV.CC" Date: Fri, 23 Oct 2026 00:29:59 +0800 Subject: [PATCH 1/3] fix: close security audit findings for v2.10.9 --- .github/workflows/release.yml | 2 +- CHANGELOG.md | 18 + CONTRIBUTING.md | 29 +- docs/installing.en.md | 12 +- docs/installing.md | 12 +- docs/releasing.md | 28 +- docs/security-model.en.md | 2 + docs/security-model.md | 2 + go.mod | 2 +- internal/cli/cli_e2e_test.go | 6 - internal/cli/cli_test.go | 1 - internal/cli/manage_root_test.go | 1 - internal/cli/revoke_test.go | 1 - .../releasecheck/bootstrap_commit_test.go | 532 ++++++++++++++++++ internal/releasecheck/installer_test.go | 37 +- .../releasecheck/publisher_signal_test.go | 317 +++++++++++ internal/releasecheck/toolchain_test.go | 29 + internal/releasecheck/trusted_scripts_test.go | 4 +- internal/releasecheck/workflow_test.go | 7 +- internal/schedule/schedule.go | 3 - internal/schedule/schedule_test.go | 16 +- internal/schedule/system.go | 33 -- internal/schedule/system_test.go | 25 +- internal/selfmanage/selfmanage_test.go | 37 ++ internal/sysinfo/sshd.go | 45 +- internal/sysinfo/sshd_include_test.go | 155 +++++ scripts/install.sh | 203 +++++-- scripts/prepare-release.sh | 2 +- scripts/publish-release.sh | 57 +- 29 files changed, 1428 insertions(+), 190 deletions(-) create mode 100644 internal/releasecheck/bootstrap_commit_test.go create mode 100644 internal/releasecheck/publisher_signal_test.go create mode 100644 internal/releasecheck/toolchain_test.go create mode 100644 internal/sysinfo/sshd_include_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 465bf2c..c5f4b5f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: '1.26.6' + go-version: '1.26.10' check-latest: false cache: false diff --git a/CHANGELOG.md b/CHANGELOG.md index e334aa3..313b0c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to this project are documented here. ## Unreleased +## v2.10.9 - 2026-10-23 + +- Refuse bootstrap reinstalls that would lower the installed version before + executing the signed candidate. Hold the lifecycle lock through the version + decision, atomic installation and uninstall-marker reactivation; require an + exact release and explicit consent for historical or unknown-version recovery. +- Preserve OpenSSH Include path semantics through symlink parents and globbed + `.` / `..` components. Distinguish recursive includes from completely scanned + inodes, and verify the scanner against real sshd configurations. +- Restore Latest after HUP, INT or TERM interrupts an unfinished publication, + including repeated signals. Preserve a private record of release identities + when restoration fails while removing credential-bearing temporary files. +- Raise the Go security floor and reproducible release/rebuild pins to 1.26.10; + check that these pins remain aligned. +- Remove the unused direct-at-ID cancellation API and adapters; keep cancellation + bound to verified job ownership and command contents. Document the compatibility + matrix and requirements for future lifecycle-concurrency changes. + ## v2.10.8 - 2026-09-26 - Keep automatic cleanup scheduled until absent-account database, sudoers and diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1b42350..a2f703e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ The tool lives in `cmd/` and `internal/`, and ships as a signed static Go binary ## Local Checks -**Go** — requires Go 1.26.6+: +**Go** — requires Go 1.26.10+: ```bash go build ./... @@ -62,6 +62,33 @@ For changes that touch account creation, revoke, sudoers, systemd timers, or `at - Update both languages of every affected user document when user-facing behavior changes. - Add or update tests for validation, parsing, quoting, and safety boundary changes. +Keep compatibility and recovery decisions in their existing owners: registry +cross-field validation belongs in `Record.lifecycleState`, live-account +classification in `classifyRegisteredAccount`, and at cancellation in the +owner/body-checked scheduler path. Do not add another ID-only cancellation API or +another independent interpretation of the same lifecycle flags. + +The supported compatibility matrix includes v2–v5 registry input, v1 task +namespaces, systemd and at scheduling, and native atd enablement on systemd, +OpenRC and SysV hosts. Removing a legacy branch requires an explicit supported +upgrade floor and a tested migration that leaves no old account, grant or task +without recovery. Consolidate equal contracts and shared test vectors first; +do not remove backend or identity checks merely to shorten the code. + +Explicit-name creation and legacy identity migration can hold the global +lifecycle lock during the cron/at drain. Other revocations queue behind that +operation; the 65-second drain is not an upper bound on total lock time. A future +concurrency change must persist a disabled intermediate identity, retain the +same-name barrier, and revalidate generation, uninstall state and scheduler +ownership after reacquiring the lock. The existing generated-name fast path and +asynchronous quarantine are the preferred ways to avoid that wait. + +Release and offline reconstruction use the exact patched Go version declared in +`go.mod`. Update both pins and their operator instructions together; ordinary CI +uses the latest patch within the supported Go series and cannot substitute for +checking the release pin. Test semantic boundaries with real signals and sshd +where available, in addition to checking the release scripts' structure. + ## Pull Request Checklist - [ ] `build`, `vet` (with `-printf.funcs`), `gofmt`, and `test -race` pass; integration tests pass or are unaffected. (`scripts/` changes: syntax checks, mirror receiver tests, and ShellCheck pass.) diff --git a/docs/installing.en.md b/docs/installing.en.md index 4d35bc6..b99c7d2 100644 --- a/docs/installing.en.md +++ b/docs/installing.en.md @@ -10,7 +10,7 @@ This guide is for administrators who install and maintain `linux-temp-admin`. Se - amd64 or arm64; - primary support for Debian, Ubuntu, RHEL, Rocky, AlmaLinux, Fedora, and common aaPanel environments; - best-effort support for Alpine and Arch Linux; -- root access plus curl, OpenSSL 3, sha256sum, and timeout for installation; +- root access plus curl, OpenSSL 3, sha256sum, timeout, sync, and flock for installation; - `getent` or `nslookup` for GitHub CDN fallback, so every redirect target can be validated and pinned to a public address. The binary has no dynamic-library or language-runtime dependency. Account lifecycle operations still use the system's `id`, `useradd`, `userdel`, `groupdel`, `usermod`, and `chage`; password login additionally requires `chpasswd`, while granting sudo requires `sudo` and `visudo` for pre-commit policy validation. `groupdel` removes a managed same-name private group only when `userdel` leaves it behind and the registry's `SequentialID` proves its GID; it is therefore a base dependency even on distributions where `userdel` normally removes that group itself. The tool does not fall back to a distro `adduser`/`deluser` or an arbitrary BusyBox account applet: command names alone cannot prove equivalent arguments, configuration, or compile-time shadow/group semantics. Missing tools can be installed through apt, dnf, yum, or apk after interactive confirmation; the account helpers come from Debian's `passwd`, the RPM families' `shadow-utils`, or Alpine/Arch's `shadow` package. With apt, the tool attempts both the index refresh and package installation; a failed refresh remains an error with its diagnostic even if the old cache lets installation complete. @@ -51,6 +51,16 @@ The convenience command obtains the script from the official mirror over HTTPS a Once started, the installer applies a separate strict verification chain to the binary. SHA-256, the detached ed25519 signature, architecture, and candidate version must all pass before an atomic installation to `/usr/local/sbin/linux-temp-admin`. There is no unsigned or checksum-only fallback. +Reinstallation allows the same or a newer version by default and refuses downgrades. Before executing the candidate, the installer reads its static version witness from the signed bytes and compares it with the installed file under the lifecycle lock. Custom `DEST` paths follow the same rule. An unknown installed version, unsafe installed metadata, or a historical candidate without a witness stops the default installation. The uninstall marker is validated before commit and cleared only after a successful, durable installation at the standard path; custom destinations do not reactivate that installation. + +For an intentional downgrade or recovery of an unknown installation, independently verify the target version and installer, then set both an exact release and the recovery switch when running the verified local script: + +```bash +/usr/bin/sudo env LTA_RELEASE=v2.10.7 LTA_ALLOW_DOWNGRADE=1 /bin/sh /root/verified-install.sh +``` + +`LTA_ALLOW_DOWNGRADE=1` cannot be combined with `latest` and does not bypass candidate signatures, architecture checks, or exact version matching. A first installation still has no trusted historical version floor; pin an exact release when replay resistance is required. The installer commits the verified file itself and does not invoke the candidate's local `install` command. Its exit status and output report the result; it does not emit that command's application audit event, so retain the output in deployment records. + ## Official mirror and GitHub fallback The compiled-in preferred release source is: diff --git a/docs/installing.md b/docs/installing.md index a832fd1..52a0146 100644 --- a/docs/installing.md +++ b/docs/installing.md @@ -10,7 +10,7 @@ - amd64 或 arm64; - 主要支持 Debian、Ubuntu、RHEL、Rocky、AlmaLinux、Fedora 和常见宝塔环境; - Alpine 和 Arch Linux 为尽力支持; -- 安装需要 root 权限、curl、OpenSSL 3、sha256sum 和 timeout; +- 安装需要 root 权限、curl、OpenSSL 3、sha256sum、timeout、sync 和 flock; - GitHub CDN 回退还需要 `getent` 或 `nslookup`,用于验证并固定每个重定向目标的公网地址。 二进制本身不依赖动态库或语言运行时。账号生命周期仍会使用系统的 `id`、`useradd`、`userdel`、`groupdel`、`usermod` 和 `chage`;密码登录还需要 `chpasswd`,授予 sudo 时还需要 `sudo` 和用于写入前策略校验的 `visudo`。`groupdel` 用于清理 `userdel` 可能留下、且已由登记中的 `SequentialID` 证明 GID 的受管同名私有组;它因此属于基础依赖,即使目标发行版上的 `userdel` 通常会自行删除该组。程序不回退到发行版 `adduser`/`deluser` 或任意 BusyBox 账号 applet:这些实现的参数、配置及编译期 shadow/group 语义不能仅凭命令名证明与 shadow 工具链等价。缺失依赖可在交互确认后通过 apt、dnf、yum 或 apk 安装;账号 helper 分别由 Debian 系的 `passwd`、RPM 系的 `shadow-utils` 或 Alpine/Arch 的 `shadow` 包提供。使用 apt 时,程序会依次尝试更新索引和安装软件包;即使旧缓存让安装完成,索引更新失败仍会作为错误返回并保留诊断。 @@ -51,6 +51,16 @@ curl -fsSL https://dl.ll.cd/linux-temp-admin/install.sh | /usr/bin/sudo /bin/sh 安装器启动后,二进制下载进入另一条严格验证链:SHA-256、detached ed25519 签名、架构和候选版本全部通过后,才会原子安装到 `/usr/local/sbin/linux-temp-admin`。不存在未验签或只有校验和的降级路径。 +重复安装默认允许同版和新版,拒绝降级。安装器在执行候选程序之前读取已签名字节中的静态版本见证,并在生命周期锁内与当前安装文件的静态版本比较;自定义 `DEST` 使用相同规则。当前版本无法确认、当前文件元数据不安全或历史候选缺少版本见证时,默认停止。卸载标记也会在提交前验证;只有标准路径安装成功并确认文件持久化后才清除标记,自定义路径不会重新激活标准安装。 + +有意降级或修复未知安装时,先独立确认目标版本和安装脚本,再对已验证的本地脚本同时设置精确版本与恢复开关,例如: + +```bash +/usr/bin/sudo env LTA_RELEASE=v2.10.7 LTA_ALLOW_DOWNGRADE=1 /bin/sh /root/verified-install.sh +``` + +`LTA_ALLOW_DOWNGRADE=1` 不能与 `latest` 一起使用,也不会绕过候选签名、架构和精确版本检查。首次安装仍没有可信的历史版本下限,需要防止历史版本回放时应固定精确发行标签。安装器自行提交已验证文件,不调用候选的本地 `install` 命令;安装结果以其退出状态和输出为准,不生成该命令的应用审计事件,请在部署记录中保留安装输出。 + ## 官方镜像与 GitHub 回退 程序内置的首选发布源是: diff --git a/docs/releasing.md b/docs/releasing.md index d8b28f1..5a87b5d 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -132,8 +132,8 @@ unset TAR_OPTIONS GZIP BZIP2 BZIP XZ_OPT GOTELEMETRY=off GOAUTH=off timeout -k 5 30 go version); then fail "cannot execute the trusted Go toolchain" fi - [[ "$(awk '{print $3}' <<<"$trusted_go_version")" == go1.26.6 ]] \ - || fail "trusted Go toolchain is not exactly go1.26.6" + [[ "$(awk '{print $3}' <<<"$trusted_go_version")" == go1.26.10 ]] \ + || fail "trusted Go toolchain is not exactly go1.26.10" signer_arch=$(env -i PATH="$PATH" LC_ALL=C HOME=/root GOROOT= GOENV=off \ GOTOOLCHAIN=local GOFLAGS= GOWORK=off GOEXPERIMENT= GOFIPS140=off \ GOTELEMETRY=off GOAUTH=off timeout -k 5 30 go env GOARCH) \ @@ -254,7 +254,7 @@ unset TAR_OPTIONS GZIP BZIP2 BZIP XZ_OPT LTA_TRUSTED_SIGNER ``` -The preparation workstation must likewise have Go 1.26.6 installed as its +The preparation workstation must likewise have Go 1.26.10 installed as its local toolchain. An automatically downloaded toolchain is not sufficient: the version gate and every reproducible build use `GOENV=off GOTOOLCHAIN=local GOFLAGS= GOWORK=off` so they cannot inspect one compiler and build with another @@ -449,7 +449,7 @@ API and require `verification.verified=true`, `verification.reason=valid`, and an armored OpenPGP signature before continuing. Use that same fingerprint as `LTA_EXPECTED_TAG_SIGNER_FINGERPRINT` during preparation and publication. -The `Release` workflow uses exactly Go 1.26.6. Its read-only `gate-build` job +The `Release` workflow uses exactly Go 1.26.10. Its read-only `gate-build` job runs vet, uncached race tests, package-serialized root integration tests, formatting, shell checks, the mirror receiver policy tests, a clean-worktree check, and @@ -478,7 +478,7 @@ objects, checks the commit's ancestry against GitHub's `main` rather than a caller-configured `origin`, rejects symlinks and submodules, and exports the self-contained tree with `git archive`. Ignored/untracked files and candidate scripts therefore cannot affect the build. It checks the successful Release -workflow and draft, rebuilds with exactly Go 1.26.6, `GOWORK=off`, and the CI +workflow and draft, rebuilds with exactly Go 1.26.10, `GOWORK=off`, and the CI flags, then `cmp`s every CI binary byte-for-byte. Draft assets are fetched one at a time through the authenticated Release Asset API after an advertised-size preflight and under a kernel file-size limit. The source archive has its own size @@ -640,9 +640,12 @@ stable release, it clears Latest and confirms the REST Latest route is exactly a 404; authentication or transport errors never count as that empty state. The Latest route is then independently compared, checksummed, and signature-verified for both architectures, followed by another highest-version -check and a final immutable-Release check. An EXIT trap performs the same exact -restoration after any error or catchable terminating signal following a possibly -applied promotion. +check and a final immutable-Release check. Explicit HUP, INT, and TERM handlers +exit with a nonzero status and invoke the same EXIT cleanup after a possibly +applied promotion. Cleanup attempts exact restoration once and ignores repeated +HUP, INT, and TERM signals while it runs. Its external helpers remain bounded by +timeouts; if restoration fails, a private recovery record is retained as described +below. SIGKILL and machine loss cannot execute cleanup. If a publish request fails or returns an ambiguous response while the same bound Release is public but still mutable, the publisher returns that exact numeric ID to draft and verifies the rollback. It never follows a replaced tag to another @@ -684,6 +687,15 @@ published release to make a retry pass. If the script reports that automatic Latest restoration itself failed, keep the release unannounced and retain the organization-wide publication lock. Restore network/API access, rerun the same publisher first, and inspect its exact result. +The script prints the path to a private `/tmp/lta-publish-recovery.*/context.tsv` +record (directory mode 0700, file mode 0600). It records the repository, candidate +tag and numeric Release ID, commit, independently pinned signed-bundle manifest +hash, baseline highest stable tag and ID, and failure status. Cleanup removes the +credential-bearing publication workspace with a bounded command and reports any +removal failure. The retained record contains no GitHub token, GitHub +configuration, environment dump, or raw API response. Keep this record and the +original signed bundle until recovery is independently verified, then remove the +record. Read the TSV as data; do not source or execute it. For manual incident recovery only, use the following fail-closed procedure. It uses decimal-string comparison rather than machine-sized arithmetic, rejects a noncanonical stable tag or mutable recovery target, excludes the failed `TAG`, diff --git a/docs/security-model.en.md b/docs/security-model.en.md index 95dbfa8..abf8f29 100644 --- a/docs/security-model.en.md +++ b/docs/security-model.en.md @@ -123,6 +123,8 @@ The revoke task rechecks name, UID/GID, Home, the exact fifth-field generation w The official installer and default official upgrades use the embedded ed25519 keyring to verify canonical `SHA256SUMS`, the current-architecture binary, its detached signature, architecture, and version as one release set. The official mirror is the preferred complete source. After a valid mirror index, only a transport failure discards the complete set and redownloads it from the same GitHub tag; a transport failure obtaining the index queries GitHub Latest. Manifest-semantic, checksum, signature, and candidate-version failures stop immediately. The scope of that binding is worth stating: the detached signature covers raw bytes only — no asset name and no architecture — and the amd64 and arm64 builds of one release carry a byte-identical version witness, while `SHA256SUMS`, the only artifact that binds a name to a digest, is unsigned. Both the installer and the upgrade path therefore read EI_CLASS/EI_DATA/e_machine straight out of the candidate's ELF header before installing it and require a match with this host's architecture, refusing another architecture's equally valid signed binary. +Rerunning the bootstrap installer permits only the same or a newer version by default. Before executing the candidate, it reads the unique static version witness from the signed bytes and checks the trusted installed file under the global lifecycle lock, held through atomic commit, durability checks and uninstall-marker handling. Unknown installed versions, unsafe installed metadata and historical candidates without a witness are refused by default. Explicit recovery requires both an exact `LTA_RELEASE` and `LTA_ALLOW_DOWNGRADE=1`; signatures, architecture and candidate version remain checked. Custom `DEST` paths also refuse default downgrades but do not clear the standard installation's uninstall marker. Record the installer's exit status and output in deployment records; it does not emit the local `install` subcommand's application audit event. + A custom `upgrade --url` or `--url-file` verifies the operator-selected binary against its detached ed25519 signature and the embedded keyring, but does not fetch `SHA256SUMS`. The local `install` command copies the already-running inode without network access or an additional signature check; the operator must independently trust that binary first. Neither upgrade path has an unsigned fallback. Release binaries from v2.9.5 carry a canonical version witness inside the signed bytes. A routine self-upgrade reads that witness without executing the candidate and applies the no-downgrade policy first; only an allowed candidate is then run under the bounded version probe, whose output must match the signed witness and selected release. Historical signed binaries without this witness are not executed by a non-forced upgrade. An operator may use `upgrade --force` for deliberate recovery or downgrade after independently identifying such a binary; signature verification and the bounded version probe still apply. diff --git a/docs/security-model.md b/docs/security-model.md index a644025..30f531f 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -123,6 +123,8 @@ invite、revoke、cleanup、install、upgrade 和 uninstall 的受管状态提 官方安装器和默认官方升级使用内置 ed25519 公钥 keyring,把规范 `SHA256SUMS`、当前架构二进制、detached 签名、架构和版本作为同一套发行文件验证。需要说明这套绑定的边界:detached 签名只覆盖裸字节,不含资产名,也不含架构,而同一发行版的 amd64 与 arm64 构建携带逐字节相同的版本见证;唯一把文件名绑定到摘要的 `SHA256SUMS` 本身没有签名。因此安装器与升级路径都会在安装前直接读取候选二进制 ELF 头的 EI_CLASS/EI_DATA/e_machine,要求它与本机架构一致,拒绝被换成另一架构那份签名同样有效的二进制。官方镜像是首选完整来源;取得有效镜像索引后,只有传输故障才丢弃整套文件并从同一 GitHub tag 重新下载。索引本身传输失败时查询 GitHub Latest;manifest 语义、校验和、签名或候选版本失败立即停止。 +重跑引导安装器默认只允许同版本或新版。执行候选之前,脚本从已验签字节读取唯一静态版本见证,在全局生命周期锁内核对可信已安装文件的版本,并持锁至原子提交、持久化与卸载标记处理结束。默认拒绝未知当前版本、不安全的已安装元数据和无见证历史候选;显式恢复要求精确 `LTA_RELEASE` 与 `LTA_ALLOW_DOWNGRADE=1`,仍验证签名、架构和候选版本。自定义 `DEST` 也禁止默认降级,但不清除标准安装的卸载标记。安装器结果由退出状态和部署输出记录,不生成本地 `install` 子命令的应用审计事件。 + 自定义 `upgrade --url` 或 `--url-file` 会使用内置 keyring,根据 detached ed25519 签名验证管理员选定的二进制,但不下载 `SHA256SUMS`。本地 `install` 命令不联网,也不额外验签,只复制已经运行的 inode;管理员必须先独立信任该二进制。两种升级路径都不存在未验签回退。 从 v2.9.5 起,发行二进制在已签名字节中携带规范版本见证。普通自升级无需执行候选即可读取该见证,并先应用禁止降级策略;只有允许升级的候选随后才会进入受限时长和输出的版本探测,且输出必须同时匹配签名见证和选定发行版。非强制升级不会执行缺少该见证的历史签名二进制。管理员独立确认历史文件后,可使用 `upgrade --force` 进行有意恢复或降级;签名验证和受限版本探测仍不会跳过。 diff --git a/go.mod b/go.mod index 750a6a4..6cad595 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/xxvcc/linux-temp-admin -go 1.26.6 +go 1.26.10 require ( golang.org/x/crypto v0.56.0 diff --git a/internal/cli/cli_e2e_test.go b/internal/cli/cli_e2e_test.go index cc46f4f..0e1bc33 100644 --- a/internal/cli/cli_e2e_test.go +++ b/internal/cli/cli_e2e_test.go @@ -44,7 +44,6 @@ func (fakeSched) Systemctl(...string) error { return nil } func (fakeSched) HasAt() bool { return true } func (fakeSched) ScheduleAt(string, time.Time) (string, error) { return "1", nil } func (fakeSched) RemoveAtJobsFor(string) error { return nil } -func (fakeSched) AtrmJob(string) error { return nil } func (fakeSched) AtJobs() ([]schedule.AtJob, error) { return nil, nil } func (fakeSched) AtDaemonRunning() (bool, error) { return true, nil } @@ -57,7 +56,6 @@ func (unavailableSched) ScheduleAt(string, time.Time) (string, error) { return "", errors.New("unavailable") } func (unavailableSched) RemoveAtJobsFor(string) error { return nil } -func (unavailableSched) AtrmJob(string) error { return nil } func (unavailableSched) AtJobs() ([]schedule.AtJob, error) { return nil, nil } func (unavailableSched) AtDaemonRunning() (bool, error) { return false, nil } @@ -95,10 +93,6 @@ func (s *trackingSched) RemoveAtJobsFor(command string) error { } return nil } -func (s *trackingSched) AtrmJob(id string) error { - delete(s.jobs, id) - return nil -} func (s *trackingSched) AtDaemonRunning() (bool, error) { return true, nil } func (s *trackingSched) AtJobs() ([]schedule.AtJob, error) { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 9f34930..1dd89d7 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -46,7 +46,6 @@ func (failingScheduleSystem) Systemctl(...string) error { ret func (failingScheduleSystem) HasAt() bool { return true } func (failingScheduleSystem) ScheduleAt(string, time.Time) (string, error) { return "", nil } func (failingScheduleSystem) RemoveAtJobsFor(string) error { return nil } -func (failingScheduleSystem) AtrmJob(string) error { return nil } func (failingScheduleSystem) AtDaemonRunning() (bool, error) { return true, nil } func (failingScheduleSystem) AtJobs() ([]schedule.AtJob, error) { diff --git a/internal/cli/manage_root_test.go b/internal/cli/manage_root_test.go index a629937..30de936 100644 --- a/internal/cli/manage_root_test.go +++ b/internal/cli/manage_root_test.go @@ -32,7 +32,6 @@ func (fakeSys) Systemctl(...string) error { return nil } func (fakeSys) HasAt() bool { return false } func (fakeSys) ScheduleAt(string, time.Time) (string, error) { return "", nil } func (fakeSys) RemoveAtJobsFor(string) error { return nil } -func (fakeSys) AtrmJob(string) error { return nil } func (fakeSys) AtJobs() ([]schedule.AtJob, error) { return nil, nil } func (fakeSys) AtDaemonRunning() (bool, error) { return true, nil } diff --git a/internal/cli/revoke_test.go b/internal/cli/revoke_test.go index 5af094c..89210d0 100644 --- a/internal/cli/revoke_test.go +++ b/internal/cli/revoke_test.go @@ -41,7 +41,6 @@ func (s revokeTestScheduleSystem) RemoveAtJobsFor(string) error { } return s.removeAtErr } -func (revokeTestScheduleSystem) AtrmJob(string) error { return nil } func (revokeTestScheduleSystem) AtJobs() ([]schedule.AtJob, error) { return nil, nil } func (revokeTestScheduleSystem) AtDaemonRunning() (bool, error) { return true, nil } diff --git a/internal/releasecheck/bootstrap_commit_test.go b/internal/releasecheck/bootstrap_commit_test.go new file mode 100644 index 0000000..c2684e0 --- /dev/null +++ b/internal/releasecheck/bootstrap_commit_test.go @@ -0,0 +1,532 @@ +package releasecheck + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/xxvcc/linux-temp-admin/internal/lifecycle" + "github.com/xxvcc/linux-temp-admin/internal/version" +) + +func bootstrapCommitFunctions(t *testing.T) string { + t.Helper() + source := readReleaseFile(t, "../../scripts/install.sh") + start := strings.Index(source, "# BEGIN BOOTSTRAP_COMMIT_FUNCTIONS") + end := strings.Index(source, "# END BOOTSTRAP_COMMIT_FUNCTIONS") + if start < 0 || end <= start { + t.Fatal("missing bootstrap commit functions") + } + return source[start:end] +} + +func bootstrapShells(t *testing.T) [][]string { + t.Helper() + var shells [][]string + for _, name := range []string{"sh", "bash", "dash", "busybox"} { + path, err := exec.LookPath(name) + if err != nil { + continue + } + args := []string{path} + if name == "busybox" { + args = append(args, "ash") + } + shells = append(shells, args) + } + return shells +} + +func TestBootstrapVersionWitnessAcrossShells(t *testing.T) { + for _, shell := range bootstrapShells(t) { + for _, tc := range []struct { + name, binary, want string + invalid bool + }{ + {"canonical", "\x00LTA_RELEASE_VERSION_V1{2.10.8}\x00", "2.10.8", false}, + {"prerelease", "LTA_RELEASE_VERSION_V1{2.10.8-rc.12}", "2.10.8-rc.12", false}, + {"absent", "old binary", "", false}, + {"noncanonical", "LTA_RELEASE_VERSION_V1{02.10.8}", "", false}, + {"overlong", "LTA_RELEASE_VERSION_V1{2.10.8-" + strings.Repeat("x", 128) + "}", "", false}, + {"duplicate", "LTA_RELEASE_VERSION_V1{2.10.8}\x00LTA_RELEASE_VERSION_V1{2.10.8}", "", true}, + } { + t.Run(filepath.Base(shell[0])+"/"+tc.name, func(t *testing.T) { + dir := t.TempDir() + binary := filepath.Join(dir, "binary") + if err := os.WriteFile(binary, []byte(tc.binary), 0o600); err != nil { + t.Fatal(err) + } + body := "set -eu\nMAX_RELEASE_VERSION_BYTES=128\n" + bootstrapCommitFunctions(t) + "\nread_release_version \"$TEST_BINARY\"\n" + cmd := exec.Command(shell[0], append(append([]string{}, shell[1:]...), "-c", body)...) + cmd.Env = append(os.Environ(), "tmp="+dir, "TEST_BINARY="+binary) + out, err := cmd.CombinedOutput() + if (err != nil) != tc.invalid || (!tc.invalid && string(out) != tc.want) { + t.Fatalf("version=%q error=%v want %q invalid=%v", out, err, tc.want, tc.invalid) + } + }) + } + } +} + +func TestBootstrapVersionOrderMatchesGo(t *testing.T) { + versions := []string{"0.0.0", "2.10.8", "2.10.9", "2.10.8-rc.9", "2.10.8-rc.10", "2.10.8-rc.01", "2.10.8-rc.1", "2.10.8-a", "2.10.8-aa", "2.10.8-9", "2.10.8-10", "99999999999999999999999999999999999.0.0", "100000000000000000000000000000000000.0.0"} + var input, want strings.Builder + for _, a := range versions { + for _, b := range versions { + fmt.Fprintf(&input, "%s %s\n", a, b) + if version.Greater(b, a) { + want.WriteString("older\n") + } else { + want.WriteString("not-older\n") + } + } + } + for _, shell := range bootstrapShells(t) { + t.Run(filepath.Base(shell[0]), func(t *testing.T) { + body := "set -eu\n" + bootstrapCommitFunctions(t) + "\nwhile read -r a b; do compare_release_versions \"$a\" \"$b\"; done\n" + cmd := exec.Command(shell[0], append(append([]string{}, shell[1:]...), "-c", body)...) + cmd.Stdin = strings.NewReader(input.String()) + out, err := cmd.CombinedOutput() + if err != nil || string(out) != want.String() { + t.Fatalf("Shell/Go version order differs: %v\n%s", err, out) + } + }) + } +} + +type bootstrapFixture struct { + dir, dest, lock, marker, executed, releaseProbe, script string + candidate []byte +} + +// Exercise the actual commit tail in a private temporary tree. Root-only path, +// owner and ELF checks are tested separately and by the integration installer; +// here only owner expectations and these fixture boundaries are adapted so the +// concurrency/state tests can run unprivileged without touching host state. +func newBootstrapFixture(t *testing.T, current, candidate string, blockProbe bool) *bootstrapFixture { + t.Helper() + dir := t.TempDir() + f := &bootstrapFixture{dir: dir, dest: filepath.Join(dir, "installed"), lock: filepath.Join(dir, "lifecycle.lock"), executed: filepath.Join(dir, "executed"), releaseProbe: filepath.Join(dir, "release-probe"), script: filepath.Join(dir, "install.sh")} + f.marker = f.lock + ".uninstalled" + if current != "" { + content := "unknown installation" + if current != "unknown" { + content = "LTA_RELEASE_VERSION_V1{" + current + "}" + } + if err := os.WriteFile(f.dest, []byte(content), 0o755); err != nil { + t.Fatal(err) + } + } + witness := "# LTA_RELEASE_VERSION_V1{" + candidate + "}\n" + if candidate == "historical" { + candidate, witness = "2.10.7", "" + } + f.candidate = []byte("#!/bin/sh\n" + witness + "[ \"$1\" = version ] || exit 93\nprintf executed > \"$TEST_EXECUTED\"\n") + if blockProbe { + f.candidate = append(f.candidate, []byte("while [ ! -f \"$TEST_RELEASE_PROBE\" ]; do sleep 0.02; done\n")...) + } + f.candidate = append(f.candidate, []byte("printf '%s\\n' '"+candidate+"'\n")...) + if err := os.WriteFile(filepath.Join(dir, "bin"), f.candidate, 0o600); err != nil { + t.Fatal(err) + } + functions := bootstrapCommitFunctions(t) + functions = strings.ReplaceAll(functions, "= 0:600", fmt.Sprintf("= %d:600", os.Geteuid())) + functions = strings.ReplaceAll(functions, "= 0:0:", fmt.Sprintf("= %d:%d:", os.Geteuid(), os.Getegid())) + source := readReleaseFile(t, "../../scripts/install.sh") + start := strings.Index(source, "\nsigned_version=$(read_release_version") + if start < 0 { + t.Fatal("missing bootstrap commit boundary") + } + tail := source[start:] + tail = strings.ReplaceAll(tail, `"$final_uid" -eq 0`, fmt.Sprintf(`"$final_uid" -eq %d`, os.Geteuid())) + tail = strings.ReplaceAll(tail, `"$final_gid" -eq 0`, fmt.Sprintf(`"$final_gid" -eq %d`, os.Getegid())) + body := `set -eu +umask 077 +fail() { echo "error: $*" >&2; exit 1; } +check_safe_dir_chain() { :; } +sync_destination_directory_chain() { :; } +elf_machine_matches() { :; } +chown() { :; } +MAX_RELEASE_VERSION_BYTES=128 +MAX_BINARY_BYTES=67108864 +MAX_PROBE_BYTES=256 +elf_machine=62 +arch=fixture +dest_dir=$tmp +existing_ancestor=$tmp +stage="" +cleanup() { if [ -n "$stage" ]; then rm -f -- "$stage"; fi; } +trap cleanup EXIT +trap 'exit 1' HUP INT TERM +got=$(sha256sum "$tmp/bin" | awk '{print $1}') +` + functions + tail + if err := os.WriteFile(f.script, []byte(body), 0o700); err != nil { + t.Fatal(err) + } + return f +} + +func (f *bootstrapFixture) command(shell []string, candidate, allow string, custom bool) *exec.Cmd { + managed := f.dest + if custom { + managed += "-other" + } + if candidate == "historical" { + candidate = "2.10.7" + } + cmd := exec.Command(shell[0], append(append([]string{}, shell[1:]...), f.script)...) + cmd.Env = append(os.Environ(), "tmp="+f.dir, "DEST="+f.dest, "MANAGED_DEST="+managed, "LIFECYCLE_LOCK="+f.lock, "allow_downgrade="+allow, "expected_version="+candidate, "TEST_EXECUTED="+f.executed, "TEST_RELEASE_PROBE="+f.releaseProbe) + return cmd +} + +func TestBootstrapRejectsBeforeCandidateExecution(t *testing.T) { + for _, shell := range bootstrapShells(t) { + for _, custom := range []bool{false, true} { + for _, tc := range []struct { + name, current, candidate, allow string + ok bool + }{ + {"downgrade", "2.10.8", "2.10.7", "0", false}, + {"same", "2.10.8", "2.10.8", "0", true}, + {"newer", "2.10.8", "2.10.9", "0", true}, + {"fresh", "", "2.10.8", "0", true}, + {"unknown current", "unknown", "2.10.8", "0", false}, + {"historical candidate", "2.10.8", "historical", "0", false}, + {"explicit downgrade", "2.10.8", "2.10.7", "1", true}, + {"explicit recovery", "unknown", "2.10.8", "1", true}, + {"explicit historical", "2.10.8", "historical", "1", true}, + } { + t.Run(fmt.Sprintf("%s/custom=%t/%s", filepath.Base(shell[0]), custom, tc.name), func(t *testing.T) { + f := newBootstrapFixture(t, tc.current, tc.candidate, false) + before, _ := os.ReadFile(f.dest) + out, err := f.command(shell, tc.candidate, tc.allow, custom).CombinedOutput() + if (err == nil) != tc.ok { + t.Fatalf("success=%v want=%v: %s", err == nil, tc.ok, out) + } + _, probeErr := os.Stat(f.executed) + if (probeErr == nil) != tc.ok { + t.Fatalf("candidate executed=%v want=%v", probeErr == nil, tc.ok) + } + after, _ := os.ReadFile(f.dest) + want := before + if tc.ok { + want = f.candidate + } + if !bytes.Equal(after, want) { + t.Fatal("unexpected installed bytes") + } + }) + } + } + } +} + +func TestBootstrapExplicitRecoveryRequiresPinnedRelease(t *testing.T) { + source := readReleaseFile(t, "../../scripts/install.sh") + start := strings.Index(source, "case \"$release\" in") + end := strings.Index(source[start:], "\n# Bash uses 1024-byte") + if start < 0 || end < 0 { + t.Fatal("missing installer option validation") + } + body := "set -eu\nMAX_RELEASE_VERSION_BYTES=128\nfail() { echo \"$*\" >&2; exit 1; }\n" + source[start:start+end] + for _, shell := range bootstrapShells(t) { + for _, tc := range []struct { + release, allow string + ok bool + }{{"latest", "1", false}, {"", "1", false}, {"v2.10.7", "yes", false}, {"v2.10.7", "1", true}, {"latest", "0", true}} { + t.Run(filepath.Base(shell[0])+"/"+tc.release+"/"+tc.allow, func(t *testing.T) { + cmd := exec.Command(shell[0], append(append([]string{}, shell[1:]...), "-c", body)...) + cmd.Env = append(os.Environ(), "release="+tc.release, "allow_downgrade="+tc.allow) + out, err := cmd.CombinedOutput() + if (err == nil) != tc.ok { + t.Fatalf("explicit recovery validation: %v\n%s", err, out) + } + }) + } + } +} + +func TestBootstrapUninstallMarker(t *testing.T) { + for _, tc := range []struct { + name, content string + custom, ok bool + }{ + {"reactivation", "uninstalled-v1\n", false, true}, + {"invalid", "uninstalled-v2\n", false, false}, + {"missing newline", "uninstalled-v1", false, false}, + {"custom preserves marker", "uninstalled-v1\n", true, true}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newBootstrapFixture(t, "2.10.8", "2.10.8", false) + if err := os.WriteFile(f.marker, []byte(tc.content), 0o600); err != nil { + t.Fatal(err) + } + out, err := f.command([]string{"/bin/sh"}, "2.10.8", "0", tc.custom).CombinedOutput() + if (err == nil) != tc.ok { + t.Fatalf("marker result: %v\n%s", err, out) + } + _, markerErr := os.Stat(f.marker) + if (markerErr == nil) != (tc.custom || !tc.ok) { + t.Fatalf("marker exists=%v", markerErr == nil) + } + _, probeErr := os.Stat(f.executed) + if !tc.ok && probeErr == nil { + t.Fatal("unsafe marker allowed candidate execution") + } + }) + } +} + +func TestBootstrapVersionComparisonFailureStopsBeforeProbe(t *testing.T) { + for _, shell := range bootstrapShells(t) { + for _, injected := range []string{"return 1", "return 2", "return 42", "return 127", "return 0", "echo unexpected", "printf 'not-older\\nolder\\n'", "echo not-older; return 1"} { + t.Run(filepath.Base(shell[0])+"/"+injected, func(t *testing.T) { + f := newBootstrapFixture(t, "2.10.8", "2.10.7", false) + source, err := os.ReadFile(f.script) + if err != nil { + t.Fatal(err) + } + source = bytes.Replace(source, []byte("\nsigned_version=$(read_release_version"), []byte("\ncompare_release_versions() { "+injected+"; }\nsigned_version=$(read_release_version"), 1) + if err := os.WriteFile(f.script, source, 0o700); err != nil { + t.Fatal(err) + } + out, err := f.command(shell, "2.10.7", "0", false).CombinedOutput() + if err == nil || !strings.Contains(string(out), "could not compare") { + t.Fatalf("comparison error did not fail closed: %v\n%s", err, out) + } + if _, err := os.Stat(f.executed); !os.IsNotExist(err) { + t.Fatal("candidate executed after comparison failure") + } + }) + } + } +} + +func TestBootstrapUnsafeStateStopsBeforeProbe(t *testing.T) { + for _, kind := range []string{"lock mode", "lock symlink", "lock fifo", "destination symlink", "marker symlink", "marker mode"} { + t.Run(kind, func(t *testing.T) { + f := newBootstrapFixture(t, "2.10.7", "2.10.8", false) + var err error + switch kind { + case "lock mode": + err = os.WriteFile(f.lock, nil, 0o600) + if err == nil { + err = os.Chmod(f.lock, 0o666) + } + case "lock symlink": + err = os.Symlink(f.dest, f.lock) + case "lock fifo": + err = syscall.Mkfifo(f.lock, 0o600) + case "destination symlink": + err = os.Remove(f.dest) + if err == nil { + err = os.Symlink(filepath.Join(f.dir, "bin"), f.dest) + } + case "marker symlink": + err = os.Symlink(f.dest, f.marker) + case "marker mode": + err = os.WriteFile(f.marker, []byte("uninstalled-v1\n"), 0o600) + if err == nil { + err = os.Chmod(f.marker, 0o644) + } + } + if err != nil { + t.Fatal(err) + } + out, err := f.command([]string{"/bin/sh"}, "2.10.8", "0", false).CombinedOutput() + if err == nil { + t.Fatalf("unsafe %s accepted: %s", kind, out) + } + if _, err := os.Stat(f.executed); !os.IsNotExist(err) { + t.Fatal("candidate ran with unsafe installed state") + } + }) + } +} + +func TestBootstrapCommitDurabilityAndReactivation(t *testing.T) { + for _, fault := range []string{"file sync", "directory sync", "marker remove", "marker sync"} { + t.Run(fault, func(t *testing.T) { + f := newBootstrapFixture(t, "2.10.7", "2.10.8", false) + if err := os.WriteFile(f.marker, []byte("uninstalled-v1\n"), 0o600); err != nil { + t.Fatal(err) + } + source, err := os.ReadFile(f.script) + if err != nil { + t.Fatal(err) + } + inject := ` +timeout() { + if [ "$4" = sync ]; then + case "$TEST_FAULT" in + 'file sync') [ "$5" != "$DEST" ] || return 44 ;; + 'directory sync') [ "$5" != "$dest_dir" ] || return 44 ;; + 'marker sync') [ "$5" != "$dest_dir" ] || [ -e "$LIFECYCLE_LOCK.uninstalled" ] || return 44 ;; + esac + fi + command timeout "$@" +} +rm() { + if [ "$TEST_FAULT" = 'marker remove' ] && [ "$2" = "$LIFECYCLE_LOCK.uninstalled" ]; then return 45; fi + command rm "$@" +} +` + source = bytes.Replace(source, []byte("\nsigned_version=$(read_release_version"), []byte(inject+"\nsigned_version=$(read_release_version"), 1) + if err := os.WriteFile(f.script, source, 0o700); err != nil { + t.Fatal(err) + } + cmd := f.command([]string{"/bin/sh"}, "2.10.8", "0", false) + cmd.Env = append(cmd.Env, "TEST_FAULT="+fault) + out, runErr := cmd.CombinedOutput() + markerGone := fault == "marker sync" + if (runErr == nil) != markerGone { + t.Fatalf("durability result: %v\n%s", runErr, out) + } + if markerGone && !strings.Contains(string(out), "installed and reactivated but marker-removal durability is unknown") { + t.Fatalf("missing committed marker warning: %s", out) + } + if !markerGone && !strings.Contains(string(out), "installed") { + t.Fatalf("failure did not disclose visible installation: %s", out) + } + installed, _ := os.ReadFile(f.dest) + if !bytes.Equal(installed, f.candidate) { + t.Fatal("fixture did not reach visible installation") + } + _, markerErr := os.Stat(f.marker) + if os.IsNotExist(markerErr) != markerGone { + t.Fatalf("marker state=%v", markerErr) + } + }) + } +} + +func TestBootstrapProbeDescendantDoesNotInheritLifecycleLock(t *testing.T) { + f := newBootstrapFixture(t, "2.10.7", "2.10.8", false) + f.candidate = append(f.candidate, []byte("sleep 30 &\nprintf '%s' \"$!\" > \"$TEST_CHILD_PID\"\n")...) + if err := os.WriteFile(filepath.Join(f.dir, "bin"), f.candidate, 0o600); err != nil { + t.Fatal(err) + } + pidPath := filepath.Join(f.dir, "child.pid") + cmd := f.command([]string{"/bin/sh"}, "2.10.8", "0", false) + cmd.Env = append(cmd.Env, "TEST_CHILD_PID="+pidPath) + out, err := cmd.CombinedOutput() + pidBytes, readErr := os.ReadFile(pidPath) + if readErr != nil { + t.Fatalf("candidate did not create its child: %v\n%s", readErr, out) + } + pid, parseErr := strconv.Atoi(string(pidBytes)) + if parseErr != nil || pid <= 1 { + t.Fatalf("invalid child PID: %q", pidBytes) + } + t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL) }) + if err != nil { + t.Fatalf("installation failed: %v\n%s", err, out) + } + if err := syscall.Kill(pid, 0); err != nil { + t.Fatalf("descendant exited before inheritance check: %v", err) + } + if _, err := os.Stat(fmt.Sprintf("/proc/%d/fd/9", pid)); !os.IsNotExist(err) { + t.Fatalf("probe descendant kept lock descriptor: %v", err) + } + release, err := lifecycle.New(f.lock).TryAcquire() + if err != nil { + t.Fatalf("probe descendant kept lifecycle lock: %v", err) + } + _ = release() +} + +func TestBootstrapAndGoShareLifecycleLock(t *testing.T) { + t.Run("rechecks after queued newer installation", func(t *testing.T) { + f := newBootstrapFixture(t, "2.10.7", "2.10.8", false) + lock := lifecycle.New(f.lock) + release, err := lock.Acquire() + if err != nil { + t.Fatal(err) + } + cmd := f.command([]string{"/bin/sh"}, "2.10.8", "0", false) + var output bytes.Buffer + cmd.Stdout, cmd.Stderr = &output, &output + if err := cmd.Start(); err != nil { + _ = release() + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case err := <-done: + _ = release() + t.Fatalf("bootstrap did not wait for Go lock: %v\n%s", err, output.String()) + case <-time.After(150 * time.Millisecond): + } + if err := os.WriteFile(f.dest, []byte("LTA_RELEASE_VERSION_V1{2.10.9}"), 0o755); err != nil { + t.Fatal(err) + } + if err := release(); err != nil { + t.Fatal(err) + } + if err := <-done; err == nil || !strings.Contains(output.String(), "refusing downgrade") { + t.Fatalf("queued install did not recheck version: %v\n%s", err, output.String()) + } + if _, err := os.Stat(f.executed); !os.IsNotExist(err) { + t.Fatal("older queued candidate was executed") + } + }) + for _, terminate := range []bool{false, true} { + t.Run(fmt.Sprintf("shell holds lock through probe/terminate=%t", terminate), func(t *testing.T) { + f := newBootstrapFixture(t, "2.10.7", "2.10.8", true) + cmd := f.command([]string{"/bin/sh"}, "2.10.8", "0", false) + var output bytes.Buffer + cmd.Stdout, cmd.Stderr = &output, &output + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cmd.Process.Kill() }) + deadline := time.Now().Add(5 * time.Second) + for { + if _, err := os.Stat(f.executed); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatal("candidate did not reach probe") + } + time.Sleep(10 * time.Millisecond) + } + lock := lifecycle.New(f.lock) + if release, err := lock.TryAcquire(); !errors.Is(err, lifecycle.ErrBusy) { + if release != nil { + _ = release() + } + t.Fatalf("Go acquired lock during bootstrap: %v", err) + } + if terminate { + _ = cmd.Process.Signal(syscall.SIGTERM) + } + if err := os.WriteFile(f.releaseProbe, nil, 0o600); err != nil { + t.Fatal(err) + } + err := cmd.Wait() + if (err != nil) != terminate { + t.Fatalf("bootstrap exit=%v\n%s", err, output.String()) + } + release, err := lock.TryAcquire() + if err != nil { + t.Fatalf("bootstrap left lifecycle lock held: %v", err) + } + _ = release() + if terminate { + content, _ := os.ReadFile(f.dest) + if string(content) != "LTA_RELEASE_VERSION_V1{2.10.7}" { + t.Fatal("terminated installer committed candidate") + } + } + }) + } +} diff --git a/internal/releasecheck/installer_test.go b/internal/releasecheck/installer_test.go index a7f2810..8fcaf05 100644 --- a/internal/releasecheck/installer_test.go +++ b/internal/releasecheck/installer_test.go @@ -33,7 +33,7 @@ func TestInstallerSyncsDestinationDirectoryChainAcrossShells(t *testing.T) { if start < 0 { t.Fatal("could not locate installer directory-chain sync function") } - endRel := strings.Index(installer[start:], "\n}\n\ncheck_safe_dir_chain") + endRel := strings.Index(installer[start:], "\n}\n\n# BEGIN BOOTSTRAP_COMMIT_FUNCTIONS") if endRel < 0 { t.Fatal("could not isolate installer directory-chain sync function") } @@ -225,7 +225,7 @@ func TestInstallerInspectionFailuresAreFailClosedAcrossShells(t *testing.T) { t.Fatal("could not locate installer inspection functions") } fetchEndRel := strings.Index(installer[fetchStart:], "\n}\n\nfetch()") - checkEndRel := strings.Index(installer[checkStart:], "\n}\n\ncheck_safe_dir_chain") + checkEndRel := strings.Index(installer[checkStart:], "\n}\n\nsync_destination_directory_chain") if fetchEndRel < 0 || checkEndRel < 0 { t.Fatal("could not isolate installer inspection functions") } @@ -482,29 +482,24 @@ func InstallerPinnedReleaseEndToEnd(t *testing.T) { dest := filepath.Join(destDir, "linux-temp-admin") coreEvidence := filepath.Join(destDir, "core-limits") activationEvidence := filepath.Join(destDir, "activation-evidence") - activationBlock := filepath.Join(destDir, "activation-block") - tombstone := filepath.Join(destDir, "uninstalled-marker") + tombstone := dest + ".lock.uninstalled" if err := os.WriteFile(tombstone, []byte("uninstalled-v1\n"), 0o600); err != nil { t.Fatal(err) } candidate := elfWrappedScript(t, fmt.Sprintf(` +# LTA_RELEASE_VERSION_V1{2.8.0} case "$1" in version) awk '$1 == "Max" && $2 == "core" && $3 == "file" && $4 == "size" { print $5, $6 }' /proc/self/limits > "$LTA_CORE_EVIDENCE" printf '2.8.0\n' ;; --lang) - [ "$#" -eq 4 ] && [ "$2" = en ] && [ "$3" = install ] && [ "$4" = --force ] || exit 76 - [ ! -e '%s' ] || exit 77 - /bin/cp -- "$0" '%s' - /bin/chown 0:0 '%s' - /bin/chmod 0755 '%s' - /bin/rm -f -- '%s' - printf 'delegated\n' > '%s' + printf 'unexpected install invocation\n' > '%s' + exit 77 ;; *) exit 78 ;; esac -`, activationBlock, dest, dest, dest, tombstone, activationEvidence)) +`, activationEvidence)) pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) @@ -550,6 +545,7 @@ esac installer = strings.ReplaceAll(installer, "MANAGED_DEST=/usr/local/sbin/linux-temp-admin", "MANAGED_DEST="+dest) + installer = strings.ReplaceAll(installer, "LIFECYCLE_LOCK=/run/linux-temp-admin.lock", "LIFECYCLE_LOCK="+dest+".lock") keyStart := strings.Index(installer, "RELEASE_PUBKEY_PEMS='") if keyStart < 0 { t.Fatal("installer keyring start not found") @@ -630,12 +626,12 @@ esac t.Fatalf("version mismatch changed target: %v", statErr) } - if err := os.WriteFile(activationBlock, []byte("block\n"), 0o600); err != nil { + if err := os.WriteFile(tombstone, []byte("invalid-marker\n"), 0o600); err != nil { t.Fatal(err) } out, err = run("v2.8.0") - if err == nil || !strings.Contains(string(out), "could not complete the managed install/reactivation") { - t.Fatalf("candidate install refusal was not fail-closed: err=%v\n%s", err, out) + if err == nil || !strings.Contains(string(out), "uninstall marker") { + t.Fatalf("unsafe uninstall marker was not fail-closed: err=%v\n%s", err, out) } if _, statErr := os.Lstat(dest); !os.IsNotExist(statErr) { t.Fatalf("candidate install refusal changed target: %v", statErr) @@ -643,7 +639,7 @@ esac if _, statErr := os.Stat(tombstone); statErr != nil { t.Fatalf("candidate install refusal removed the uninstall marker: %v", statErr) } - if err := os.Remove(activationBlock); err != nil { + if err := os.WriteFile(tombstone, []byte("uninstalled-v1\n"), 0o600); err != nil { t.Fatal(err) } @@ -658,11 +654,11 @@ esac if !bytes.Equal(installed, candidate) { t.Fatal("installed bytes differ from the signed candidate") } - if evidence, err := os.ReadFile(activationEvidence); err != nil || string(evidence) != "delegated\n" { - t.Fatalf("managed install was not delegated to the signed candidate: evidence=%q err=%v", evidence, err) + if _, err := os.Stat(activationEvidence); !os.IsNotExist(err) { + t.Fatalf("bootstrap invoked candidate install while holding the lifecycle lock: %v", err) } if _, err := os.Lstat(tombstone); !os.IsNotExist(err) { - t.Fatalf("successful delegated install did not clear the uninstall marker: %v", err) + t.Fatalf("successful install did not clear the uninstall marker: %v", err) } fi, err := os.Stat(dest) if err != nil { @@ -699,7 +695,7 @@ func InstallerOfficialMirrorFallbackBoundary(t *testing.T) { } asset := "linux-temp-admin-linux-" + runtime.GOARCH - candidate := elfWrappedScript(t, "[ \"$1\" = version ] && printf '2.8.0\\n'\n") + candidate := elfWrappedScript(t, "# LTA_RELEASE_VERSION_V1{2.8.0}\n[ \"$1\" = version ] && printf '2.8.0\\n'\n") pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) @@ -809,6 +805,7 @@ func InstallerOfficialMirrorFallbackBoundary(t *testing.T) { installer := readReleaseFile(t, "../../scripts/install.sh") installer = strings.ReplaceAll(installer, "MIRROR_ROOT=https://dl.ll.cd/linux-temp-admin", "MIRROR_ROOT="+server.URL+"/mirror") installer = strings.ReplaceAll(installer, "GITHUB_RELEASE_ROOT=https://github.com/xxvcc/linux-temp-admin/releases", "GITHUB_RELEASE_ROOT="+server.URL+"/github") + installer = strings.ReplaceAll(installer, "LIFECYCLE_LOCK=/run/linux-temp-admin.lock", "LIFECYCLE_LOCK=$DEST.lock") installer = strings.ReplaceAll(installer, "MIRROR_FETCH_ATTEMPTS=2", "MIRROR_FETCH_ATTEMPTS=1") installer = strings.ReplaceAll(installer, "GITHUB_FETCH_ATTEMPTS=4", "GITHUB_FETCH_ATTEMPTS=1") keyStart := strings.Index(installer, "RELEASE_PUBKEY_PEMS='") diff --git a/internal/releasecheck/publisher_signal_test.go b/internal/releasecheck/publisher_signal_test.go new file mode 100644 index 0000000..6fe354f --- /dev/null +++ b/internal/releasecheck/publisher_signal_test.go @@ -0,0 +1,317 @@ +package releasecheck + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +// Run the production traps and restoration functions in an isolated process +// group. GitHub is a local state fixture; these tests never publish a release. +func TestPublisherSignalsRestoreLatestAndKeepPrivateRecoveryEvidence(t *testing.T) { + for _, signal := range []syscall.Signal{syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM} { + for _, group := range []bool{false, true} { + for _, phase := range []string{"before", "armed", "complete"} { + name := fmt.Sprintf("%s/group=%t/%s", signal, group, phase) + t.Run(name, func(t *testing.T) { + runPublisherSignalFixture(t, publisherSignalCase{signal: signal, group: group, phase: phase}) + }) + } + t.Run(fmt.Sprintf("%s/group=%t/restore-failure", signal, group), func(t *testing.T) { + runPublisherSignalFixture(t, publisherSignalCase{signal: signal, group: group, phase: "armed", failRestore: true}) + }) + } + } +} + +func TestPublisherRepeatedSignalsDoNotInterruptRecovery(t *testing.T) { + for _, group := range []bool{false, true} { + for _, failRestore := range []bool{false, true} { + t.Run(fmt.Sprintf("group=%t/restore-failure=%t", group, failRestore), func(t *testing.T) { + runPublisherSignalFixture(t, publisherSignalCase{ + signal: syscall.SIGTERM, group: group, phase: "armed", repeat: true, failRestore: failRestore, + }) + }) + } + } +} + +func TestPublisherIncompleteExitRestoresLatest(t *testing.T) { + for _, status := range []int{0, 42} { + t.Run(fmt.Sprintf("status=%d", status), func(t *testing.T) { + runPublisherSignalFixture(t, publisherSignalCase{phase: "armed", exitStatus: status}) + }) + } +} + +func TestPublisherRecoveryRecordFailurePreventsMutation(t *testing.T) { + runPublisherSignalFixture(t, publisherSignalCase{phase: "armed", failRecord: true, exitStatus: 42}) +} + +type publisherSignalCase struct { + signal syscall.Signal + group bool + phase string + failRestore bool + repeat bool + failRecord bool + exitStatus int +} + +func runPublisherSignalFixture(t *testing.T, tc publisherSignalCase) { + t.Helper() + publish := readReleaseFile(t, "../../scripts/publish-release.sh") + trapStart := strings.Index(publish, "LATEST_PROMOTION_ATTEMPTED=0\n") + trapEnd := strings.Index(publish, "\n# Pin the verifier by descriptor") + recoveryStart := strings.Index(publish, "decimal_gt() {") + recoveryEnd := strings.Index(publish, "\nrequire_remote_tag_object() {") + if trapStart < 0 || trapEnd <= trapStart || recoveryStart < 0 || recoveryEnd <= recoveryStart { + t.Fatal("could not isolate production publisher traps and recovery functions") + } + recoveryFunctions := strings.Replace(publish[recoveryStart:recoveryEnd], + "restore_latest_after_failed_promotion() {", "restore_latest_under_test() {", 1) + dir := t.TempDir() + script := filepath.Join(dir, "publisher-signals.sh") + body := `#!/bin/bash +set -Eeuo pipefail +umask 077 +TAG=v2.8.0 +REPO=mock/repo +EXPECTED_RELEASE_ID=280 +COMMIT=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +LTA_EXPECTED_SIGNED_BUNDLE_MANIFEST_SHA256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +BASELINE_HIGHEST_TAG=v2.7.3 +BASELINE_HIGHEST_ID=273 +LOCAL_COMMAND_TIMEOUT_SECONDS=5 +MAX_RELEASE_VERSION_BYTES=128 +work="$TEST_STATE/work" +mkdir -p "$work/gh-config" "$TEST_STATE/signed-source" +printf '%s' "$GH_TOKEN" > "$work/gh-config/hosts.yml" +printf '%s' "$GITHUB_TOKEN" > "$work/raw-api-response" +printf 'retained original signed bundle' > "$TEST_STATE/signed-source/bundle.bin" +printf 'v2.7.3\nv2.8.0\n' > "$TEST_STATE/stable" +printf 'v2.7.3 273' > "$TEST_STATE/latest" +mktemp() { + [[ "$TEST_FAIL_RECORD" == false ]] || return 1 + local created + created="$(command mktemp "$@")" || return 1 + printf '%s' "$created" > "$TEST_STATE/recovery-path" + printf '%s\n' "$created" +} +gh() { + if [[ "$1" == api && "$2" == --paginate ]]; then cat "$TEST_STATE/stable"; return 0; fi + if [[ "$1" == release && "$2" == view ]]; then cut -d ' ' -f1 "$TEST_STATE/latest"; return 0; fi + if [[ "$1" == api && "$2" == repos/mock/repo/releases/latest ]]; then + read -r tag id extra < "$TEST_STATE/latest" + [[ -z "$extra" && "$id" =~ ^[1-9][0-9]*$ ]] || return 98 + printf '%s false false true %s\n' "$tag" "$id" + return 0 + fi + if [[ "$1" == api && "$2" == repos/mock/repo/releases/tags/v2.7.3 ]]; then + printf 'v2.7.3 false false true 273\n' + return 0 + fi + if [[ "$1" == api && "$2" == --method && "$3" == PATCH \ + && "$4" == repos/mock/repo/releases/273 ]]; then + printf 'v2.7.3 273' > "$TEST_STATE/latest" + printf 'v2.7.3 false false true 273\n' + return 0 + fi + return 99 +} +gh_with_timeout() { gh "$@"; } +` + recoveryFunctions + ` +restore_latest_after_failed_promotion() { + printf 'restore\n' >> "$TEST_STATE/restore-calls" + : > "$TEST_STATE/restore-started" + if [[ "$TEST_REPEAT" == true ]]; then + while [[ ! -e "$TEST_STATE/release-recovery" ]]; do sleep 0.01; done + fi + [[ "$TEST_FAIL_RESTORE" == false ]] || return 1 + restore_latest_under_test +} +` + publish[trapStart:trapEnd] + ` +if [[ "$TEST_PHASE" != before ]]; then + arm_latest_restoration + : > "$TEST_STATE/mutation-attempted" + printf 'v2.8.0 280' > "$TEST_STATE/latest" +fi +if [[ "$TEST_PHASE" == complete ]]; then PUBLISH_COMPLETE=1; fi +: > "$TEST_STATE/ready" +if [[ "$TEST_SIGNAL" == 0 ]]; then exit "$TEST_EXIT_STATUS"; fi +while :; do sleep 0.01; done +` + if err := os.WriteFile(script, []byte(body), 0o700); err != nil { + t.Fatal(err) + } + const secret = "publisher-test-credential-must-never-survive" + cmd := exec.Command("/bin/bash", "-p", script) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Env = append(os.Environ(), "TEST_STATE="+dir, "TEST_PHASE="+tc.phase, + fmt.Sprintf("TEST_SIGNAL=%d", tc.signal), fmt.Sprintf("TEST_REPEAT=%t", tc.repeat), + fmt.Sprintf("TEST_FAIL_RESTORE=%t", tc.failRestore), fmt.Sprintf("TEST_FAIL_RECORD=%t", tc.failRecord), + fmt.Sprintf("TEST_EXIT_STATUS=%d", tc.exitStatus), "GH_TOKEN="+secret, "GITHUB_TOKEN="+secret) + var output bytes.Buffer + cmd.Stdout = &output + cmd.Stderr = &output + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + done := make(chan struct{}) + var waitErr error + go func() { + waitErr = cmd.Wait() + close(done) + }() + t.Cleanup(func() { + // All children belong to this fixture's fresh process group. Kill any + // survivors even if an assertion failed while Bash was awaiting a signal. + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + select { + case <-done: + case <-time.After(5 * time.Second): + t.Error("publisher signal fixture did not exit after SIGKILL") + } + if recoveryPath, err := os.ReadFile(filepath.Join(dir, "recovery-path")); err == nil { + if !strings.HasPrefix(string(recoveryPath), "/tmp/lta-publish-recovery.") { + t.Errorf("unexpected fixture recovery path: %q", recoveryPath) + return + } + if err := os.RemoveAll(string(recoveryPath)); err != nil { + t.Error(err) + } + } + }) + waitForMarker := func(name string) { + t.Helper() + deadline := time.NewTimer(10 * time.Second) + defer deadline.Stop() + ticker := time.NewTicker(5 * time.Millisecond) + defer ticker.Stop() + for { + if _, err := os.Stat(filepath.Join(dir, name)); err == nil { + return + } + select { + case <-done: + t.Fatalf("publisher exited before %s: %v\n%s", name, waitErr, output.String()) + case <-deadline.C: + t.Fatalf("timed out awaiting publisher marker %s", name) + case <-ticker.C: + } + } + } + if tc.signal != 0 { + waitForMarker("ready") + target := cmd.Process.Pid + if tc.group { + target = -target + } + if err := syscall.Kill(target, tc.signal); err != nil { + t.Fatal(err) + } + if tc.repeat { + waitForMarker("restore-started") + for _, signal := range []syscall.Signal{syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM} { + if err := syscall.Kill(target, signal); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(dir, "release-recovery"), nil, 0o600); err != nil { + t.Fatal(err) + } + } + } + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("publisher did not complete its signal cleanup") + } + wantStatus := tc.exitStatus + if tc.signal != 0 { + wantStatus = 128 + int(tc.signal) + } else if tc.failRecord || wantStatus == 0 { + wantStatus = 1 + } + exitErr, ok := waitErr.(*exec.ExitError) + if !ok || exitErr.ExitCode() != wantStatus { + t.Fatalf("publisher exit = %v, want %d\n%s", waitErr, wantStatus, output.String()) + } + if strings.Contains(output.String(), secret) { + t.Fatal("publisher printed a credential during cleanup") + } + if _, err := os.Stat(filepath.Join(dir, "work")); !os.IsNotExist(err) { + t.Fatalf("credential-bearing workspace survived cleanup: %v", err) + } + if bundle, err := os.ReadFile(filepath.Join(dir, "signed-source/bundle.bin")); err != nil || string(bundle) != "retained original signed bundle" { + t.Fatalf("cleanup changed the source signed bundle: %q, %v", bundle, err) + } + wantRestoration := tc.phase == "armed" && !tc.failRecord + calls, err := os.ReadFile(filepath.Join(dir, "restore-calls")) + if wantRestoration { + if err != nil || string(calls) != "restore\n" { + t.Fatalf("restoration must run exactly once: %q, %v\n%s", calls, err, output.String()) + } + } else if !os.IsNotExist(err) { + t.Fatalf("publisher restored Latest without an incomplete mutation: %q, %v", calls, err) + } + wantLatest := "v2.7.3 273" + if tc.phase == "complete" || tc.failRestore { + wantLatest = "v2.8.0 280" + } + if latest, err := os.ReadFile(filepath.Join(dir, "latest")); err != nil || string(latest) != wantLatest { + t.Fatalf("Latest = %q, %v; want %s\n%s", latest, err, wantLatest, output.String()) + } + recoveryPath, err := os.ReadFile(filepath.Join(dir, "recovery-path")) + if tc.phase == "before" || tc.failRecord { + if !os.IsNotExist(err) { + t.Fatalf("unexpected recovery record path: %q, %v", recoveryPath, err) + } + if tc.failRecord { + if _, err := os.Stat(filepath.Join(dir, "mutation-attempted")); !os.IsNotExist(err) { + t.Fatalf("publisher mutated Latest despite recovery record failure: %v", err) + } + } + return + } + if err != nil { + t.Fatal(err) + } + if !tc.failRestore { + if _, err := os.Stat(string(recoveryPath)); !os.IsNotExist(err) { + t.Fatalf("successful restoration or completed publication retained recovery record: %v", err) + } + return + } + contextPath := filepath.Join(string(recoveryPath), "context.tsv") + context, err := os.ReadFile(contextPath) + if err != nil { + t.Fatalf("failed restoration lost recovery evidence: %v\n%s", err, output.String()) + } + wantContext := "linux-temp-admin publication recovery v1\n" + + "repo\tmock/repo\ntag\tv2.8.0\nrelease-id\t280\ncommit\t" + strings.Repeat("a", 40) + "\n" + + "signed-bundle-manifest-sha256\t" + strings.Repeat("b", 64) + "\n" + + "baseline-highest-tag\tv2.7.3\nbaseline-highest-release-id\t273\n" + fmt.Sprintf("exit-status\t%d\n", wantStatus) + if string(context) != wantContext || strings.Contains(string(context), secret) { + t.Fatalf("retained recovery record differs from the exact credential-free identity record: %q", context) + } + for path, mode := range map[string]os.FileMode{string(recoveryPath): 0o700, contextPath: 0o600} { + info, err := os.Stat(path) + if err != nil || info.Mode().Perm() != mode { + t.Fatalf("recovery evidence %s must have mode %o: %v, %v", path, mode, info, err) + } + } + entries, err := os.ReadDir(string(recoveryPath)) + if err != nil || len(entries) != 1 || entries[0].Name() != "context.tsv" { + t.Fatalf("failed restoration retained files other than minimal recovery evidence: %v, %v", entries, err) + } + if !strings.Contains(output.String(), "private recovery identifiers retained at: "+contextPath) { + t.Fatalf("publisher did not identify its retained recovery record: %s", output.String()) + } +} diff --git a/internal/releasecheck/toolchain_test.go b/internal/releasecheck/toolchain_test.go new file mode 100644 index 0000000..9757ac1 --- /dev/null +++ b/internal/releasecheck/toolchain_test.go @@ -0,0 +1,29 @@ +package releasecheck + +import ( + "regexp" + "testing" +) + +// Release and independent reconstruction must use the same patched toolchain. +// A floating ordinary CI version does not protect an accidentally stale release +// pin. Keep the module's security floor as the shared version contract. +func TestReleaseToolchainsMatchModuleSecurityFloor(t *testing.T) { + module := readReleaseFile(t, "../../go.mod") + version := regexp.MustCompile(`(?m)^go ([0-9]+\.[0-9]+\.[0-9]+)$`).FindStringSubmatch(module) + if len(version) != 2 { + t.Fatal("go.mod must declare an exact patched Go security floor") + } + for _, source := range []struct { + path string + pattern string + }{ + {"../../.github/workflows/release.yml", `(?m)^\s+go-version: '([0-9]+\.[0-9]+\.[0-9]+)'$`}, + {"../../scripts/prepare-release.sh", `(?m)^GO_VERSION="go([0-9]+\.[0-9]+\.[0-9]+)"$`}, + } { + matches := regexp.MustCompile(source.pattern).FindAllStringSubmatch(readReleaseFile(t, source.path), -1) + if len(matches) != 1 || matches[0][1] != version[1] { + t.Errorf("%s must pin exactly Go %s; found %v", source.path, version[1], matches) + } + } +} diff --git a/internal/releasecheck/trusted_scripts_test.go b/internal/releasecheck/trusted_scripts_test.go index 6a274c5..c9aa14c 100644 --- a/internal/releasecheck/trusted_scripts_test.go +++ b/internal/releasecheck/trusted_scripts_test.go @@ -539,14 +539,14 @@ func TestPrepareRejectsFailingGoVersionCommandWithValidLookingOutput(t *testing. t.Fatal(err) } mockGo := filepath.Join(binDir, "go") - if err := os.WriteFile(mockGo, []byte("#!/bin/sh\necho 'go version go1.26.6 linux/amd64'\nexit 23\n"), 0o700); err != nil { + if err := os.WriteFile(mockGo, []byte("#!/bin/sh\necho 'go version go1.26.10 linux/amd64'\nexit 23\n"), 0o700); err != nil { t.Fatal(err) } marker := filepath.Join(dir, "gate-passed") script := filepath.Join(dir, "version-gate.sh") body := `#!/bin/bash set -Eeuo pipefail -GO_VERSION=go1.26.6 +GO_VERSION=go1.26.10 ` + versionGate + ` : > "$TEST_MARKER" ` diff --git a/internal/releasecheck/workflow_test.go b/internal/releasecheck/workflow_test.go index 7ded34a..973bf53 100644 --- a/internal/releasecheck/workflow_test.go +++ b/internal/releasecheck/workflow_test.go @@ -543,7 +543,10 @@ func TestReleaseScriptsBindSignerIdentityAndClientSizeLimit(t *testing.T) { for _, required := range []string{ "MANAGED_DEST=/usr/local/sbin/linux-temp-admin", `[ "$DEST" = "$MANAGED_DEST" ]`, - `"$stage" --lang en install --force`, + "LIFECYCLE_LOCK=/run/linux-temp-admin.lock", + "acquire_bootstrap_lock\ncheck_bootstrap_state", + "signed candidate has no static release-version witness", + "refusing downgrade from $current_version to $signed_version", "installed destination differs from the verified candidate", `stat -c %g -- "$DEST"`, "installed destination group is not root", @@ -553,7 +556,7 @@ func TestReleaseScriptsBindSignerIdentityAndClientSizeLimit(t *testing.T) { `sync_destination_directory_chain "$dest_dir"`, } { if !strings.Contains(installer, required) { - t.Fatalf("installer does not delegate managed-path lifecycle activation: missing %q", required) + t.Fatalf("installer does not preserve locked, verified installation: missing %q", required) } } if !strings.Contains(installer, "OPENSSL_CONF=/dev/null") || !strings.Contains(installer, "unset OPENSSL_CONF_INCLUDE OPENSSL_MODULES OPENSSL_ENGINES") { diff --git a/internal/schedule/schedule.go b/internal/schedule/schedule.go index 8b5618b..6abcfdc 100644 --- a/internal/schedule/schedule.go +++ b/internal/schedule/schedule.go @@ -36,8 +36,6 @@ type System interface { // RemoveAtJobsFor removes queued jobs matching a known standalone revoke // command selected by command's legacy-compatible prefix. RemoveAtJobsFor(command string) error - // AtrmJob removes a specific at job by id. An already-absent job is success. - AtrmJob(id string) error // AtJobs returns queued job bodies and their generated owner UID so uninstall // can inventory root-created jobs whose registry row has been lost. Missing // inventory commands or an unparseable owner header are errors. @@ -261,7 +259,6 @@ func (s systemdOnlySystem) ScheduleAt(string, time.Time) (string, error) { return "", fmt.Errorf("at is disabled for identity quarantine") } func (s systemdOnlySystem) RemoveAtJobsFor(string) error { return nil } -func (s systemdOnlySystem) AtrmJob(string) error { return nil } func (s systemdOnlySystem) AtJobs() ([]AtJob, error) { return nil, nil } func (s *Scheduler) scheduleSystemd(user string, uid int, generation string, deadline time.Time) (string, error) { diff --git a/internal/schedule/schedule_test.go b/internal/schedule/schedule_test.go index 9889991..7844bff 100644 --- a/internal/schedule/schedule_test.go +++ b/internal/schedule/schedule_test.go @@ -20,11 +20,9 @@ type fakeSystem struct { atDeadline time.Time atID string removedFor []string - atrmd []string atJobs []AtJob loadedUnits []string removeAtErr error - atrmErr error atJobsErr error loadedErr error systemctlErr func(args ...string) error @@ -53,7 +51,6 @@ func (f *fakeSystem) RemoveAtJobsFor(command string) error { f.removedFor = append(f.removedFor, command) return f.removeAtErr } -func (f *fakeSystem) AtrmJob(id string) error { f.atrmd = append(f.atrmd, id); return f.atrmErr } func (f *fakeSystem) AtJobs() ([]AtJob, error) { return f.atJobs, f.atJobsErr } // atdStopped/atdProbeErr default to a running daemon so existing cases keep their @@ -175,8 +172,8 @@ func TestCancelQuarantineNeverSweepsExpiryAtJobs(t *testing.T) { if err := s.CancelQuarantine("xxvcc-a1", unit); err != nil { t.Fatal(err) } - if len(sys.removedFor) != 0 || len(sys.atrmd) != 0 { - t.Fatalf("quarantine cleanup touched at jobs: sweep=%v atrm=%v", sys.removedFor, sys.atrmd) + if len(sys.removedFor) != 0 { + t.Fatalf("quarantine cleanup touched at jobs: sweep=%v", sys.removedFor) } } @@ -824,9 +821,6 @@ func TestCancelNeverRemovesARecordedAtIDWithoutBodyVerification(t *testing.T) { if err := s.Cancel("xxvcc-a1", "at:42"); err != nil { t.Fatal(err) } - if len(sys.atrmd) != 0 { - t.Fatalf("Cancel passed a reusable recorded id directly to atrm: %v", sys.atrmd) - } if len(sys.removedFor) != 1 { t.Fatalf("Cancel did not use the verified command-body sweep: %v", sys.removedFor) } @@ -839,9 +833,6 @@ func TestCancelKeepsRecordedAtEvidenceWithoutInventoryBackend(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "at backend is unavailable") { t.Fatalf("Cancel error = %v, want missing-inventory refusal", err) } - if len(sys.atrmd) != 0 { - t.Fatalf("Cancel passed an unverified recorded id directly to atrm: %v", sys.atrmd) - } } func TestCancelPreservesUnknownRecordedScheduleEvidence(t *testing.T) { @@ -853,9 +844,6 @@ func TestCancelPreservesUnknownRecordedScheduleEvidence(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "unsupported recorded auto-revoke identifier") { t.Fatalf("Cancel(%q) error = %v, want unsupported evidence refusal", recorded, err) } - if len(sys.atrmd) != 0 { - t.Fatalf("Cancel passed an invalid recorded id to atrm: %v", sys.atrmd) - } if len(sys.removedFor) != 1 { t.Fatalf("Cancel did not still sweep exact known at commands: %v", sys.removedFor) } diff --git a/internal/schedule/system.go b/internal/schedule/system.go index 69627b4..acc59bc 100644 --- a/internal/schedule/system.go +++ b/internal/schedule/system.go @@ -421,39 +421,6 @@ func sysVAtdLinksEnabled(initDir string) bool { return true } -func (realSystem) AtrmJob(id string) error { - if id == "" { - return fmt.Errorf("empty at job id") - } - for _, r := range id { - if r < '0' || r > '9' { - return fmt.Errorf("invalid at job id %q", id) - } - } - if !has("atrm") { - return fmt.Errorf("atrm is unavailable") - } - // at removes a one-shot job from the queue before running its command. The - // firing auto-revoke therefore sees its recorded job id as already absent; - // that is the desired state, not a cleanup failure. - if queued, err := atJobQueued(id); err == nil && !queued { - return nil - } - if out, err := executil.CombinedOutput("atrm", []string{id}, schedulerCommandOptions(schedulerOutputLimit)); err != nil { - // The job may have fired between the queue check and atrm. Confirm absence - // once more before reporting the command failure. - if queued, qerr := atJobQueued(id); qerr == nil && !queued { - return nil - } - return fmt.Errorf("atrm %s: %w: %s", id, err, strings.TrimSpace(string(out))) - } - return nil -} - -func atJobQueued(id string) (bool, error) { - return atJobQueuedContext(context.Background(), id) -} - func atJobQueuedContext(ctx context.Context, id string) (bool, error) { if !has("atq") { return false, fmt.Errorf("atq is unavailable") diff --git a/internal/schedule/system_test.go b/internal/schedule/system_test.go index 715facd..6eb2cc4 100644 --- a/internal/schedule/system_test.go +++ b/internal/schedule/system_test.go @@ -41,29 +41,6 @@ func TestRealSystemHasAtDetectsEveryBackendFootprint(t *testing.T) { }) } -func TestAtrmJobTreatsAlreadyAbsentAsSuccess(t *testing.T) { - dir := t.TempDir() - writeCommand(t, dir, "atq", "exit 0") - writeCommand(t, dir, "atrm", "echo should-not-run >&2; exit 1") - t.Setenv("PATH", dir) - - if err := (realSystem{}).AtrmJob("42"); err != nil { - t.Fatalf("already-absent at job should be success: %v", err) - } -} - -func TestAtrmJobReportsFailureForStillQueuedJob(t *testing.T) { - dir := t.TempDir() - writeCommand(t, dir, "atq", "printf '42\\tFri Jul 24 00:00:00 2026 a root\\n'") - writeCommand(t, dir, "atrm", "echo removal-failed >&2; exit 1") - t.Setenv("PATH", dir) - - err := (realSystem{}).AtrmJob("42") - if err == nil || !strings.Contains(err.Error(), "removal-failed") { - t.Fatalf("AtrmJob error = %v, want the real removal failure", err) - } -} - func TestSystemctlMissingUnitErrorIsPreciselyClassified(t *testing.T) { const unit = "linux-temp-admin-v2-revoke-xxvcc-a1.timer" tests := []struct { @@ -287,7 +264,7 @@ func TestAtJobQueuedFailsClosedOnMalformedInventory(t *testing.T) { dir := t.TempDir() writeCommand(t, dir, "atq", "printf '%s\\n' 'corrupt queue line'") t.Setenv("PATH", dir) - if queued, err := atJobQueued("42"); err == nil || queued { + if queued, err := atJobQueuedContext(context.Background(), "42"); err == nil || queued { t.Fatalf("atJobQueued malformed inventory = %v, %v; want false, error", queued, err) } } diff --git a/internal/selfmanage/selfmanage_test.go b/internal/selfmanage/selfmanage_test.go index a10aa1b..420cf0a 100644 --- a/internal/selfmanage/selfmanage_test.go +++ b/internal/selfmanage/selfmanage_test.go @@ -293,6 +293,19 @@ func TestUpgradeRefusedWithoutKey(t *testing.T) { } func TestReleaseVersionWitnessIsCanonicalAndUnique(t *testing.T) { + installer, err := os.ReadFile("../../scripts/install.sh") + if err != nil { + t.Fatal(err) + } + start := strings.Index(string(installer), "read_release_version() {") + if start < 0 { + t.Fatal("installer witness reader missing") + } + end := strings.Index(string(installer)[start:], "\n}\n") + if end < 0 { + t.Fatal("installer witness reader boundary missing") + } + shellReader := string(installer)[start : start+end+2] maxVersion := "2.3.4-" + strings.Repeat("a", validate.MaxReleaseVersionBytes-len("2.3.4-")) for _, tc := range []struct { name string @@ -302,6 +315,8 @@ func TestReleaseVersionWitnessIsCanonicalAndUnique(t *testing.T) { }{ {name: "historical binary has no witness", bin: "historical"}, {name: "canonical", bin: "prefix LTA_RELEASE_VERSION_V1{2.9.5} suffix", want: "2.9.5"}, + {name: "binary nul framing", bin: "\x00LTA_RELEASE_VERSION_V1{2.9.5}\x00", want: "2.9.5"}, + {name: "nested invalid marker", bin: "LTA_RELEASE_VERSION_V1{badLTA_RELEASE_VERSION_V1{2.9.5}", want: "2.9.5"}, {name: "prerelease", bin: "LTA_RELEASE_VERSION_V1{2.10.0-rc.1}", want: "2.10.0-rc.1"}, {name: "maximum length", bin: "LTA_RELEASE_VERSION_V1{" + maxVersion + "}", want: maxVersion}, {name: "over maximum length", bin: "LTA_RELEASE_VERSION_V1{" + maxVersion + "a}"}, @@ -315,6 +330,28 @@ func TestReleaseVersionWitnessIsCanonicalAndUnique(t *testing.T) { if (err != nil) != tc.wantErr || got != tc.want { t.Fatalf("releaseVersionWitness = %q,%v, want %q, error=%v", got, err, tc.want, tc.wantErr) } + dir := t.TempDir() + binary := filepath.Join(dir, "binary") + if err := os.WriteFile(binary, []byte(tc.bin), 0o600); err != nil { + t.Fatal(err) + } + for _, shell := range []string{"sh", "bash", "dash", "busybox"} { + path, lookErr := exec.LookPath(shell) + if lookErr != nil { + continue + } + args := []string{} + if shell == "busybox" { + args = append(args, "ash") + } + body := "set -eu\nMAX_RELEASE_VERSION_BYTES=128\n" + shellReader + "\nread_release_version \"$TEST_BINARY\"" + cmd := exec.Command(path, append(args, "-c", body)...) + cmd.Env = append(os.Environ(), "tmp="+dir, "TEST_BINARY="+binary) + out, shellErr := cmd.CombinedOutput() + if (shellErr != nil) != (err != nil) || (err == nil && string(out) != got) { + t.Fatalf("%s witness disagrees with Go: %q,%v; Go %q,%v", shell, out, shellErr, got, err) + } + } }) } } diff --git a/internal/sysinfo/sshd.go b/internal/sysinfo/sshd.go index 99fc2be..9c87a51 100644 --- a/internal/sysinfo/sshd.go +++ b/internal/sysinfo/sshd.go @@ -126,7 +126,10 @@ type sshdConfigIdentity struct { } type sshdIncludeScan struct { - paths map[string]bool + // An opened inode is false while being scanned and true after a complete + // scan. Revisiting an active inode is a recursive Include, not proof that + // its contents were checked. Paths are never cleaned for identity checks: + // resolving symlink/.. in the kernel can reach a different file. identities map[sshdConfigIdentity]bool // accountExists controls whether Match Group is evaluable by `sshd -T -C // user=...`. OpenSSH cannot resolve group membership for a not-yet-created @@ -166,13 +169,12 @@ func HasConnectionScopedMatch() bool { // future account receives only the global result from `sshd -T -C user=name`. func HasUnverifiableMatch(accountExists bool) bool { files := []string{sshdConfigPath} - if entries, err := strictGlob(filepath.Join(sshdConfigDropInDir, "*.conf")); err == nil { + if entries, err := strictGlob(sshdConfigDropInDir + "/*.conf"); err == nil { files = append(files, entries...) } else { return true } scan := &sshdIncludeScan{ - paths: map[string]bool{}, identities: map[sshdConfigIdentity]bool{}, accountExists: accountExists, } @@ -190,11 +192,6 @@ func fileHasConnectionScopedMatch(path, baseDir string, scan *sshdIncludeScan, d if depth >= maxSSHDIncludeDepth { return false, false } - path = filepath.Clean(path) - if scan.paths[path] { - return false, true - } - scan.paths[path] = true // sshd follows symlinked configuration files, so this scanner does too. Open // nonblocking, then require the resolved descriptor to be regular and bounded: // a damaged Include that names a FIFO or device must downgrade the verdict @@ -213,13 +210,13 @@ func fileHasConnectionScopedMatch(path, baseDir string, scan *sshdIncludeScan, d return false, false } identity := sshdConfigIdentity{dev: uint64(st.Dev), ino: st.Ino} - if scan.identities[identity] { - return false, true + if complete, seen := scan.identities[identity]; seen { + return false, complete } if scan.files >= maxSSHDIncludeFiles || fi.Size() > maxSSHDIncludeBytes-scan.bytes { return false, false } - scan.identities[identity] = true + scan.identities[identity] = false scan.files++ scan.bytes += fi.Size() limited := &io.LimitedReader{R: f, N: maxSSHDConfigBytes + 1} @@ -252,7 +249,9 @@ func fileHasConnectionScopedMatch(path, baseDir string, scan *sshdIncludeScan, d } scan.globs++ if !filepath.IsAbs(pattern) { - pattern = filepath.Join(baseDir, pattern) + // filepath.Join would clean symlink/.. before the kernel + // resolves it, potentially scanning an unrelated file. + pattern = baseDir + "/" + pattern } matches, err := strictGlob(pattern) if err != nil { @@ -312,7 +311,9 @@ func fileHasConnectionScopedMatch(path, baseDir string, scan *sshdIncludeScan, d } } } - return false, sc.Err() == nil && limited.N > 0 + complete = sc.Err() == nil && limited.N > 0 + scan.identities[identity] = complete + return false, complete } // parseSSHDDirective accepts both forms supported by OpenSSH's configuration @@ -404,10 +405,9 @@ func readSSHDGlobDir(path string) ([]os.DirEntry, error) { return entries, nil } -// strictGlob is filepath.Glob with one security-relevant difference: directory -// I/O errors are returned instead of silently treated as no matches. An -// incomplete sshd Include scan must downgrade the login verdict, not hide a -// connection-scoped Match rule. +// strictGlob expands supported Include patterns without cleaning their paths. +// Directory I/O errors are returned instead of silently treated as no matches. +// An incomplete scan must downgrade the verdict, not hide a Match rule. func strictGlob(pattern string) ([]string, error) { if _, err := matchSSHDIncludeGlob(pattern, ""); err != nil { return nil, err @@ -469,6 +469,15 @@ func strictGlobDir(dir, pattern string, matches []string) ([]string, error) { if !fi.IsDir() { return matches, nil } + // ReadDir omits these entries, but glob(3) can traverse them after a globbed + // component (for example release-*/../policy.conf). Keep the spelling so + // symlinked directories retain the kernel's parent-directory semantics. + if pattern == "." || pattern == ".." { + if len(matches) >= maxSSHDIncludeFiles { + return nil, errSSHDGlobLimit + } + return append(matches, dir+"/"+pattern), nil + } entries, err := strictGlobReadDir(dir) if err != nil { return nil, err @@ -482,7 +491,7 @@ func strictGlobDir(dir, pattern string, matches []string) ([]string, error) { if len(matches) >= maxSSHDIncludeFiles { return nil, errSSHDGlobLimit } - matches = append(matches, filepath.Join(dir, entry.Name())) + matches = append(matches, dir+"/"+entry.Name()) } } return matches, nil diff --git a/internal/sysinfo/sshd_include_test.go b/internal/sysinfo/sshd_include_test.go new file mode 100644 index 0000000..15ee60a --- /dev/null +++ b/internal/sysinfo/sshd_include_test.go @@ -0,0 +1,155 @@ +package sysinfo + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/xxvcc/linux-temp-admin/internal/executil" + "golang.org/x/crypto/ssh" +) + +const addressRestrictedSSHDPolicy = "Match Address 203.0.113.0/24\n PubkeyAuthentication no\n PasswordAuthentication no\nMatch all\n" + +// Keep these paths textual: filepath.Join would remove the very component +// whose kernel resolution these regression tests exercise. +var symlinkParentIncludes = []struct{ name, path string }{ + {"literal", "link/../policy.conf"}, + {"file_glob", "link/../policy*.conf"}, + {"directory_glob", "li*/../policy.conf"}, + {"directory_and_file_glob", "li*/../policy*.conf"}, + {"dot_after_glob", "li*/./../policy.conf"}, +} + +func writeSSHDIncludeFixture(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func newSSHDIncludeFixture(t *testing.T) string { + t.Helper() + dir := t.TempDir() + for _, child := range []string{"real/leaf", "dropins"} { + if err := os.MkdirAll(dir+"/"+child, 0o700); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink("real/leaf", dir+"/link"); err != nil { + t.Fatal(err) + } + oldConfig, oldDropins := sshdConfigPath, sshdConfigDropInDir + sshdConfigPath, sshdConfigDropInDir = dir+"/sshd_config", dir+"/dropins" + t.Cleanup(func() { sshdConfigPath, sshdConfigDropInDir = oldConfig, oldDropins }) + return dir +} + +func TestSSHDIncludePreservesSymlinkParentResolution(t *testing.T) { + dir := newSSHDIncludeFixture(t) + for _, base := range []struct{ name, path string }{{"relative", ""}, {"absolute", dir + "/"}} { + for _, include := range symlinkParentIncludes { + pattern := base.path + include.path + t.Run(base.name+"/"+include.name, func(t *testing.T) { + writeSSHDIncludeFixture(t, sshdConfigPath, "Include "+pattern+"\n") + for _, restricted := range []bool{true, false} { + actual, decoy := "# unrestricted\n", addressRestrictedSSHDPolicy + if restricted { + actual, decoy = decoy, actual + } + writeSSHDIncludeFixture(t, dir+"/real/policy.conf", actual) + writeSSHDIncludeFixture(t, dir+"/policy.conf", decoy) + if got := HasUnverifiableMatch(true); got != restricted { + t.Fatalf("Include %q: unverifiable=%v, want %v from the kernel-resolved file", pattern, got, restricted) + } + } + }) + } + } + + // A previously scanned lexical spelling must not hide a different inode. + writeSSHDIncludeFixture(t, dir+"/policy.conf", "# harmless decoy\n") + writeSSHDIncludeFixture(t, dir+"/real/policy.conf", addressRestrictedSSHDPolicy) + writeSSHDIncludeFixture(t, sshdConfigPath, "Include policy.conf link/../policy.conf\n") + if !HasUnverifiableMatch(true) { + t.Fatal("an earlier lexical path hid the distinct symlink/.. target") + } +} + +func TestSSHDIncludeDistinguishesCompletedInodesFromCycles(t *testing.T) { + dir := newSSHDIncludeFixture(t) + writeSSHDIncludeFixture(t, dir+"/policy.conf", "Match User alice\n PubkeyAuthentication yes\nMatch all\n") + if err := os.Symlink("policy.conf", dir+"/alias.conf"); err != nil { + t.Fatal(err) + } + writeSSHDIncludeFixture(t, sshdConfigPath, "Include policy.conf alias.conf\n") + if HasUnverifiableMatch(true) { + t.Fatal("repeating a completely scanned inode must not make the policy unverifiable") + } + writeSSHDIncludeFixture(t, dir+"/policy.conf", "Include alias.conf\n") + if !HasUnverifiableMatch(true) { + t.Fatal("a recursive Include was mistaken for a complete scan") + } +} + +func TestSSHDIncludeSymlinkParentAgreesWithOpenSSH(t *testing.T) { + sshd, err := exec.LookPath("sshd") + if err != nil { + t.Skip("sshd is unavailable") + } + dir := newSSHDIncludeFixture(t) + _, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + key, err := ssh.MarshalPrivateKey(private, "sshd Include test") + if err != nil { + t.Fatal(err) + } + writeSSHDIncludeFixture(t, dir+"/host_key", string(pem.EncodeToMemory(key))) + writeSSHDIncludeFixture(t, dir+"/policy.conf", "# harmless lexical path\n") + writeSSHDIncludeFixture(t, dir+"/real/policy.conf", addressRestrictedSSHDPolicy) + + // OpenSSH resolves relative Include paths against its compiled /etc/ssh, + // even with -f. Point back to our temp fixture without writing to /etc/ssh. + relativeDir, err := filepath.Rel("/etc/ssh", dir) + if err != nil { + t.Fatal(err) + } + probe := func(t *testing.T, connection string) *SSHDConfig { + t.Helper() + out, err := executil.CombinedOutput(sshd, []string{"-T", "-f", sshdConfigPath, "-C", connection}, sshdProbeOptions) + if err != nil { + if strings.Contains(string(out), "Missing privilege separation directory") { + t.Skip("sshd's required privilege-separation directory is absent; tests do not create system paths") + } + t.Fatalf("sshd -T: %v: %s", err, out) + } + return ParseSSHD(string(out)) + } + for _, base := range []struct{ name, path string }{{"absolute", dir}, {"relative", relativeDir}} { + for _, include := range symlinkParentIncludes { + pattern := base.path + "/" + include.path + t.Run(base.name+"/"+include.name, func(t *testing.T) { + writeSSHDIncludeFixture(t, sshdConfigPath, "HostKey "+dir+"/host_key\nUsePAM no\nPubkeyAuthentication yes\nPasswordAuthentication yes\nAuthorizedKeysFile .ssh/authorized_keys\nInclude "+pattern+"\n") + withoutAddress := probe(t, "user="+acct) + withAddress := probe(t, "user="+acct+",addr=203.0.113.25,host=client.example") + for _, directive := range []string{"pubkeyauthentication", "passwordauthentication"} { + if withoutAddress.First(directive) != "yes" || withAddress.First(directive) != "no" { + t.Fatalf("sshd fixture did not expose the address-dependent %s policy: without=%q with=%q", directive, withoutAddress.First(directive), withAddress.First(directive)) + } + } + scan := &sshdIncludeScan{identities: map[sshdConfigIdentity]bool{}, accountExists: true} + found, complete := fileHasConnectionScopedMatch(sshdConfigPath, "/etc/ssh", scan, 0) + if !found || !complete { + t.Fatalf("scanner missed the Match rule sshd used: found=%v complete=%v", found, complete) + } + }) + } + } +} diff --git a/scripts/install.sh b/scripts/install.sh index 8b4ea0f..0605a94 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -13,7 +13,7 @@ set -eu # Some systems link /bin/sh to Bash, which imports exported functions before # executing this file. Clear every command name we call before trusting PATH. for imported_name in id uname mktemp stat sha256sum openssl timeout awk grep od wc cp chmod chown mv \ - dirname mkdir rm sleep sync curl getent nslookup command printf ulimit echo umask export unset set trap exit return break ':' '['; do + dirname mkdir rm sleep sync flock curl getent nslookup command printf ulimit echo umask export unset set trap exit return break ':' '['; do unset -f "$imported_name" 2>/dev/null || : done PATH=/usr/sbin:/usr/bin:/sbin:/bin @@ -28,11 +28,13 @@ if ! ulimit -c 0 2>/dev/null; then fi release="${LTA_RELEASE-latest}" +allow_downgrade="${LTA_ALLOW_DOWNGRADE-0}" expected_version="" release_tag="" MIRROR_ROOT=https://dl.ll.cd/linux-temp-admin GITHUB_RELEASE_ROOT=https://github.com/xxvcc/linux-temp-admin/releases MANAGED_DEST=/usr/local/sbin/linux-temp-admin +LIFECYCLE_LOCK=/run/linux-temp-admin.lock DEST="${DEST:-$MANAGED_DEST}" TMP_ROOT=/tmp MAX_BINARY_BYTES=67108864 @@ -57,12 +59,9 @@ case "$DEST" in esac for command_name in id uname mktemp stat sha256sum openssl timeout awk grep od wc cp chmod chown mv \ - dirname mkdir rm sleep curl; do + dirname mkdir rm sleep sync flock curl; do command -v "$command_name" >/dev/null 2>&1 || fail "required command not found: $command_name" done -if [ "$DEST" != "$MANAGED_DEST" ]; then - command -v sync >/dev/null 2>&1 || fail "required command not found: sync" -fi case "$release" in latest) ;; @@ -79,6 +78,11 @@ case "$release" in expected_version=${release#v} ;; esac +case "$allow_downgrade" in + 0) ;; + 1) [ "$release" != latest ] || fail "LTA_ALLOW_DOWNGRADE=1 requires an exact LTA_RELEASE=vX.Y.Z" ;; + *) fail "LTA_ALLOW_DOWNGRADE must be 0 or 1" ;; +esac # Bash uses 1024-byte `ulimit -f` blocks normally but 512-byte blocks in POSIX # or sh mode; dash and BusyBox ash also use 512. Probe in a command-substitution @@ -897,23 +901,140 @@ sync_destination_directory_chain() { done } +# BEGIN BOOTSTRAP_COMMIT_FUNCTIONS +# This witness is part of the Ed25519-authenticated candidate bytes. Read it +# without running either binary; the unsigned mirror index is only a selector. +read_release_version() { + witness_status=0 + grep -aoE 'LTA_RELEASE_VERSION_V1[{](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?[}]' "$1" \ + > "$tmp/witnesses" || witness_status=$? + [ "$witness_status" -le 1 ] || return 1 + awk -v max="$MAX_RELEASE_VERSION_BYTES" ' + { value=substr($0, 24, length($0)-24); if (length(value) <= max) { count++; version=value } } + END { if (count > 1) exit 1; if (count == 1) printf "%s", version } + ' "$tmp/witnesses" +} + +# Match internal/version.Greater, including arbitrary-size decimal components, +# final > prerelease, numeric suffix runs, and their leading-zero tie breaker. +compare_release_versions() { + awk -v a="$1" -v b="$2" ' + function lexical(x,y) { return ("x" x < "x" y) ? -1 : (("x" x > "x" y) ? 1 : 0) } + function decimal(x,y) { + sub(/^0+/, "", x); sub(/^0+/, "", y) + if (length(x) != length(y)) return length(x) < length(y) ? -1 : 1 + return lexical(x,y) + } + function natural(x,y, xc,yc,xn,yn,c) { + while (length(x) && length(y)) { + xc=substr(x,1,1); yc=substr(y,1,1) + if (xc ~ /[0-9]/ && yc ~ /[0-9]/) { + match(x,/^[0-9]+/); xn=substr(x,1,RLENGTH) + match(y,/^[0-9]+/); yn=substr(y,1,RLENGTH) + c=decimal(xn,yn); if (c) return c + c=lexical(xn,yn); if (c) return c + x=substr(x,length(xn)+1); y=substr(y,length(yn)+1) + } else { + c=lexical(xc,yc); if (c) return c + x=substr(x,2); y=substr(y,2) + } + } + return length(x) ? 1 : (length(y) ? -1 : 0) + } + BEGIN { + ac=a; bc=b; sub(/-.*/, "", ac); sub(/-.*/, "", bc) + split(ac,aa,"."); split(bc,bb,".") + for (i=1;i<=3;i++) { c=decimal(aa[i],bb[i]); if (c) { print c < 0 ? "older" : "not-older"; exit } } + as=substr(a,length(ac)+1); bs=substr(b,length(bc)+1) + if (as == "" && bs != "") { print "not-older"; exit } + if (as != "" && bs == "") { print "older"; exit } + print natural(as,bs) < 0 ? "older" : "not-older" + } + ' +} + +acquire_bootstrap_lock() { + check_safe_dir_chain "$(dirname -- "$LIFECYCLE_LOCK")" + # Shell has no O_NOFOLLOW redirection. The parent chain is root-only, reject + # links/non-regular files before a non-truncating open, then bind the descriptor + # to the same inode and metadata. No unprivileged writer can race this path. + [ ! -L "$LIFECYCLE_LOCK" ] || fail "lifecycle lock is a symlink" + if [ ! -e "$LIFECYCLE_LOCK" ]; then + ( set -C; : > "$LIFECYCLE_LOCK" ) 2>/dev/null \ + || [ -f "$LIFECYCLE_LOCK" ] || fail "cannot create lifecycle lock" + fi + if [ ! -f "$LIFECYCLE_LOCK" ] || [ -L "$LIFECYCLE_LOCK" ]; then + fail "lifecycle lock is not a regular non-symlink file" + fi + [ "$(stat -c '%u:%a' -- "$LIFECYCLE_LOCK")" = 0:600 ] \ + || fail "lifecycle lock has unsafe metadata" + exec 9<> "$LIFECYCLE_LOCK" + lock_identity=$(stat -Lc '%d:%i:%u:%a' -- /proc/self/fd/9) \ + || fail "cannot inspect lifecycle lock descriptor" + [ "$lock_identity" = "$(stat -c '%d:%i:%u:%a' -- "$LIFECYCLE_LOCK")" ] \ + || fail "lifecycle lock changed while opening" + timeout -k 1 30 flock -x 9 || fail "cannot acquire lifecycle lock within 30 seconds" + if [ -L "$LIFECYCLE_LOCK" ] || \ + [ "$lock_identity" != "$(stat -c '%d:%i:%u:%a' -- "$LIFECYCLE_LOCK")" ]; then + fail "lifecycle lock changed while waiting" + fi +} + +check_bootstrap_state() { + was_uninstalled=0 + uninstall_marker="${LIFECYCLE_LOCK}.uninstalled" + if [ "$DEST" = "$MANAGED_DEST" ] && { [ -e "$uninstall_marker" ] || [ -L "$uninstall_marker" ]; }; then + if [ ! -f "$uninstall_marker" ] || [ -L "$uninstall_marker" ]; then + fail "uninstall marker is not a regular non-symlink file" + fi + [ "$(stat -c '%u:%g:%a:%s' -- "$uninstall_marker")" = 0:0:600:15 ] \ + || fail "uninstall marker has unsafe metadata or size" + [ "$(od -An -v -tx1 "$uninstall_marker" | awk '{$1=$1; printf "%s", $0}')" = '75 6e 69 6e 73 74 61 6c 6c 65 64 2d 76 31 0a' ] \ + || fail "uninstall marker has invalid content" + was_uninstalled=1 + fi + [ ! -L "$DEST" ] || fail "destination is a symlink: $DEST" + if [ -e "$DEST" ]; then + [ -f "$DEST" ] || fail "destination is not a regular file: $DEST" + if [ "$allow_downgrade" = 0 ]; then + [ "$(stat -c '%u:%g:%a' -- "$DEST")" = 0:0:755 ] \ + || fail "installed command has unsafe metadata; explicit pinned recovery is required" + [ "$(wc -c < "$DEST")" -le "$MAX_BINARY_BYTES" ] \ + || fail "installed command exceeds the size limit" + current_version=$(read_release_version "$DEST") \ + || fail "installed command has ambiguous version witnesses" + [ -n "$current_version" ] \ + || fail "installed version is unknown; explicit pinned recovery is required" + comparison=$(compare_release_versions "$signed_version" "$current_version") \ + || fail "could not compare installed and candidate versions" + case "$comparison" in + older) fail "refusing downgrade from $current_version to $signed_version; set LTA_ALLOW_DOWNGRADE=1 with an exact LTA_RELEASE only if intended" ;; + not-older) ;; + *) fail "could not compare installed and candidate versions" ;; + esac + fi + fi +} +# END BOOTSTRAP_COMMIT_FUNCTIONS + +signed_version=$(read_release_version "$tmp/bin") \ + || fail "signed candidate has ambiguous release-version witnesses" +if [ "$allow_downgrade" = 0 ] && [ -z "$signed_version" ]; then + fail "signed candidate has no static release-version witness; explicit pinned recovery is required" +fi +[ -z "$expected_version" ] || [ -z "$signed_version" ] || [ "$signed_version" = "$expected_version" ] \ + || fail "downloaded binary version does not match LTA_RELEASE" + check_safe_dir_chain "$existing_ancestor" mkdir -p -- "$dest_dir" check_safe_dir_chain "$dest_dir" -if [ "$DEST" != "$MANAGED_DEST" ]; then - # Sync every directory inode from the leaf through root, in leaf-to-parent - # order. This makes each newly created component's entry durable in its parent - # before a custom installation can report success, including on a retry after - # an earlier visible mkdir whose parent sync failed. - sync_destination_directory_chain "$dest_dir" -fi +sync_destination_directory_chain "$dest_dir" -if [ -L "$DEST" ]; then - fail "destination is a symlink: $DEST" -fi -if [ -e "$DEST" ] && [ ! -f "$DEST" ]; then - fail "destination is not a regular file: $DEST" -fi +# Keep the existing lifecycle lock from the first installed-state read through +# the candidate probe, commit and reactivation. Custom destinations use the same +# lock, so two installers cannot both approve against an obsolete version. +acquire_bootstrap_lock +check_bootstrap_state stage=$(mktemp "${dest_dir}/.linux-temp-admin.XXXXXX") if [ ! -f "$stage" ] || [ -L "$stage" ]; then @@ -934,7 +1055,7 @@ if ! ( # stdin is still the pipe carrying the rest of this script when the documented # `curl ... | sh` path is used, and the candidate is an untrusted binary at this # point. Hand it /dev/null so it cannot consume or observe the installer text. - exec timeout -k 1 10 "$stage" version > "$tmp/version" 2> "$tmp/version.err" < /dev/null + exec timeout -k 1 10 "$stage" version > "$tmp/version" 2> "$tmp/version.err" < /dev/null 9>&- ); then fail "downloaded binary failed its pre-install version probe" fi @@ -948,6 +1069,8 @@ if ! candidate_version=$(awk ' fi [ -z "$expected_version" ] || [ "$candidate_version" = "$expected_version" ] \ || fail "downloaded binary version does not match LTA_RELEASE" +[ -z "$signed_version" ] || [ "$candidate_version" = "$signed_version" ] \ + || fail "downloaded binary version does not match its signed static witness" [ "$(sha256sum "$stage" | awk '{print $1}')" = "$got" ] \ || fail "staging copy changed during the version probe" @@ -955,29 +1078,15 @@ check_safe_dir_chain "$dest_dir" if [ -L "$DEST" ] || { [ -e "$DEST" ] && [ ! -f "$DEST" ]; }; then fail "destination changed before commit: $DEST" fi -if [ "$DEST" = "$MANAGED_DEST" ]; then - # The signed candidate owns the lifecycle lock and uninstall marker protocol. - # Delegating the managed-path commit to it serializes reinstall with every - # other mutation and reactivates a deliberately uninstalled host. An unsafe - # marker is rejected before the candidate changes the stable command. - if ! timeout -k 1 30 "$stage" --lang en install --force >/dev/null 2>&1 < /dev/null; then - fail "signed candidate could not complete the managed install/reactivation" - fi - rm -f -- "$stage" || fail "could not remove the verified staging file" - stage="" +# The shell already owns the lifecycle lock. Commit the verified inode directly; +# invoking a historical candidate install would try to acquire that lock again. +timeout -k 5 30 sync "$stage" || fail "destination staging file could not be made durable" +if mv --help 2>&1 | grep -q -- '--no-target-directory'; then + mv -fT -- "$stage" "$DEST" else - # Flush the fully verified inode before its name is committed. Operand-based - # sync is supported by both GNU coreutils and BusyBox without relying on their - # differing -d/-f option details. - timeout -k 5 30 sync "$stage" \ - || fail "custom destination staging file could not be made durable" - if mv --help 2>&1 | grep -q -- '--no-target-directory'; then - mv -fT -- "$stage" "$DEST" - else - mv -f -- "$stage" "$DEST" - fi - stage="" + mv -f -- "$stage" "$DEST" fi +stage="" if [ -L "$DEST" ] || [ ! -f "$DEST" ]; then fail "installed destination is not a regular non-symlink file" @@ -1002,10 +1111,14 @@ if ! final_mode=$(stat -c %a -- "$DEST"); then fail "cannot inspect installed destination mode" fi [ "$final_mode" = 755 ] || fail "installed destination mode is not 0755" -if [ "$DEST" != "$MANAGED_DEST" ]; then - timeout -k 5 30 sync "$DEST" \ - || fail "custom destination is visible but its file durability is unknown" - timeout -k 5 30 sync "$dest_dir" \ - || fail "custom destination is visible but its directory durability is unknown" +timeout -k 5 30 sync "$DEST" \ + || fail "installed command is visible but its file durability is unknown" +timeout -k 5 30 sync "$dest_dir" \ + || fail "installed command is visible but its directory durability is unknown" +if [ "$was_uninstalled" = 1 ]; then + rm -- "$uninstall_marker" || fail "command is installed but uninstall marker could not be cleared" + timeout -k 5 30 sync "$(dirname -- "$uninstall_marker")" \ + || echo "warning: command is installed and reactivated but marker-removal durability is unknown" >&2 fi +exec 9>&- echo "installed ${DEST} (version ${candidate_version})" diff --git a/scripts/prepare-release.sh b/scripts/prepare-release.sh index 3c48c03..84bbd61 100755 --- a/scripts/prepare-release.sh +++ b/scripts/prepare-release.sh @@ -41,7 +41,7 @@ TAG="${1:?usage: prepare-release.sh vX.Y.Z /absolute/source/repo /absolute/prepa SOURCE_DIR="${2:?usage: prepare-release.sh vX.Y.Z /absolute/source/repo /absolute/prepared-dir}" OUT_DIR="${3:?usage: prepare-release.sh vX.Y.Z /absolute/source/repo /absolute/prepared-dir}" REPO="xxvcc/linux-temp-admin" -GO_VERSION="go1.26.6" +GO_VERSION="go1.26.10" MAX_BINARY_BYTES=67108864 MAX_METADATA_BYTES=1048576 MAX_SOURCE_ARCHIVE_BYTES=134217728 diff --git a/scripts/publish-release.sh b/scripts/publish-release.sh index f0e6f85..49366f5 100755 --- a/scripts/publish-release.sh +++ b/scripts/publish-release.sh @@ -235,20 +235,65 @@ printf '%s\n' '#!/bin/sh' 'exec /usr/bin/gpg --batch --no-auto-key-retrieve "$@" chmod 0700 "$gpg_wrapper" LATEST_PROMOTION_ATTEMPTED=0 PUBLISH_COMPLETE=0 +recovery_dir="" +arm_latest_restoration() { + # Commit a small, private recovery record before any possibly-applied Latest + # mutation. Never retain gh configuration, API diagnostics, or the environment: + # those may contain credentials. These identifiers and the independently pinned + # bundle hash are enough to bind the manual recovery procedure to this run. + if [[ -z "$recovery_dir" ]]; then + recovery_dir="$(mktemp -d /tmp/lta-publish-recovery.XXXXXX)" || return 1 + chmod 0700 "$recovery_dir" || return 1 + { + printf '%s\n' 'linux-temp-admin publication recovery v1' + printf 'repo\t%s\ntag\t%s\nrelease-id\t%s\ncommit\t%s\n' \ + "$REPO" "$TAG" "$EXPECTED_RELEASE_ID" "$COMMIT" + printf 'signed-bundle-manifest-sha256\t%s\n' "$LTA_EXPECTED_SIGNED_BUNDLE_MANIFEST_SHA256" + printf 'baseline-highest-tag\t%s\nbaseline-highest-release-id\t%s\n' \ + "$BASELINE_HIGHEST_TAG" "$BASELINE_HIGHEST_ID" + } > "$recovery_dir/context.tsv" || return 1 + chmod 0600 "$recovery_dir/context.tsv" || return 1 + fi + LATEST_PROMOTION_ATTEMPTED=1 +} cleanup() { - local status=$? + local status=$? preserve_recovery=0 + # Cleanup runs once. Repeated terminal/parent signals cannot interrupt its + # bookkeeping or start a second restoration. Helpers retain their timeouts; + # a process-group signal may still fail a helper, which preserves the record. + trap '' HUP INT TERM trap - EXIT - if (( status != 0 && LATEST_PROMOTION_ATTEMPTED == 1 && PUBLISH_COMPLETE == 0 )); then + set +e + if (( LATEST_PROMOTION_ATTEMPTED == 1 && PUBLISH_COMPLETE == 0 )); then + (( status != 0 )) || status=1 echo "stable publication failed after a mutation that could affect Latest; restoring the exact highest stable release other than $TAG" >&2 if ! restore_latest_after_failed_promotion; then + preserve_recovery=1 echo "CRITICAL: automatic Latest restoration failed; keep the release unannounced and follow docs/releasing.md recovery" >&2 + if [[ -n "${recovery_dir:-}" ]]; then + printf 'exit-status\t%s\n' "$status" >> "$recovery_dir/context.tsv" \ + || echo "warning: could not append the exit status to the recovery record" >&2 + echo "private recovery identifiers retained at: $recovery_dir/context.tsv" >&2 + else + echo "CRITICAL: no recovery record is available; retain the independently recorded release identity and signed bundle" >&2 + fi fi fi timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" rm -rf -- "$work" \ || echo "warning: could not remove private publication workspace within timeout: $work" >&2 + if (( preserve_recovery == 0 )) && [[ -n "${recovery_dir:-}" ]]; then + timeout -k 5 "$LOCAL_COMMAND_TIMEOUT_SECONDS" rm -rf -- "$recovery_dir" \ + || echo "warning: could not remove private recovery record within timeout: $recovery_dir" >&2 + fi exit "$status" } trap cleanup EXIT +# Bash's EXIT trap can see a preceding command's zero status when the shell is +# terminated directly. Convert each supported terminating signal explicitly; +# never infer signal failure from the EXIT trap's incoming status alone. +trap 'trap "" HUP INT TERM; exit 129' HUP +trap 'trap "" HUP INT TERM; exit 130' INT +trap 'trap "" HUP INT TERM; exit 143' TERM # Pin the verifier by descriptor so the inode hashed here is the one every # subsequent verify command executes. @@ -906,7 +951,7 @@ if (( RELEASE_WAS_DRAFT == 1 )); then # has passed byte-for-byte and signature verification. Set the restoration # guard before the call because a failed response can still follow an # applied server-side mutation. - LATEST_PROMOTION_ATTEMPTED=1 + arm_latest_restoration fi if ! publish_bound_release; then echo "publishing bound Release $EXPECTED_RELEASE_ID failed or returned an invalid response" >&2 @@ -985,7 +1030,7 @@ if [[ "$TAG" != *-* ]]; then || { echo "a higher stable release appeared; refusing to promote $TAG" >&2; exit 1; } # Set this before the mutating call: gh can fail after the server applied # the update. The EXIT trap must restore even in that ambiguous outcome. - LATEST_PROMOTION_ATTEMPTED=1 + arm_latest_restoration require_immutable_release set_latest_by_release_id "$EXPECTED_RELEASE_ID" "$TAG" true else @@ -994,7 +1039,7 @@ if [[ "$TAG" != *-* ]]; then final_highest="$(highest_stable_release_excluding "")" if [[ "$final_highest" != "$TAG" ]]; then - LATEST_PROMOTION_ATTEMPTED=1 + arm_latest_restoration if restore_latest_after_failed_promotion; then LATEST_PROMOTION_ATTEMPTED=0 echo "a higher stable release appeared during Latest promotion; restored the exact highest alternative" >&2 @@ -1011,7 +1056,7 @@ if [[ "$TAG" != *-* ]]; then require_remote_asset_digests final_highest="$(highest_stable_release_excluding "")" if [[ "$final_highest" != "$TAG" ]]; then - LATEST_PROMOTION_ATTEMPTED=1 + arm_latest_restoration if restore_latest_after_failed_promotion; then LATEST_PROMOTION_ATTEMPTED=0 echo "a higher stable release appeared during final verification; restored the exact highest alternative" >&2 From 66816b9f8bd25e6ae23fcbc413284886a5bcdb53 Mon Sep 17 00:00:00 2001 From: "XXV.CC" Date: Fri, 23 Oct 2026 00:36:11 +0800 Subject: [PATCH 2/3] docs: define shared publication lock and ownership-safe cleanup --- docs/releasing.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/releasing.md b/docs/releasing.md index 5a87b5d..5c48e7b 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -371,6 +371,31 @@ supply a durable exclusive lock that records its owner and tag and has an explicit stale-lock recovery policy. Workflow concurrency and a workstation-local `flock` do not satisfy this requirement. +For this repository, coordinators use the shared GitHub reference +`refs/tags/lta-publication-lock`. Before online preparation, create a unique +annotated tag object whose message records the release tag, exact commit, +authenticated maintainer login, UTC acquisition time and a random operation ID. +Atomically create the fixed reference with GitHub's `POST /repos/xxvcc/linux-temp-admin/git/refs`. +An existing reference means the release is busy; never update or replace it. +If the create request has an ambiguous result, read the reference and proceed +only if it points to this operation's exact tag-object SHA. Record that SHA in +the release evidence and recheck ownership before signing and publication. +The lock tag does not match the `v*` release trigger and is not a version tag. +Every workstation must use this same protocol; it coordinates participating +publishers and does not protect against an authorized maintainer deliberately +deleting the lock. A local lock may additionally coordinate older local tools. + +Retain the reference through publication, mirror synchronization and independent +public verification. Release only the owned reference with an atomic expected-SHA +check, using `git push --force-with-lease=refs/tags/lta-publication-lock: +origin :refs/tags/lta-publication-lock`. Do not use a separate GET followed by an +unconditional REST DELETE, which could remove a successor's lock. Locks do not +expire automatically. After an interrupted operation, first establish that its +recorded coordinator and workflows have stopped, inspect the bound draft/public +release and Latest state, and record the recovery decision. Resume the same +operation or remove only its recorded tag-object SHA with the same lease check; +elapsed time alone is never sufficient to reclaim a lock. + The OpenPGP tag-signing key is separate from the offline ed25519 release key. Before the first release under this process, generate or import a dedicated maintainer OpenPGP key whose UID contains an email verified on the `xxvcc` From 0030a113bce1fdbf20aa6df543febc252be240aa Mon Sep 17 00:00:00 2001 From: "XXV.CC" Date: Fri, 23 Oct 2026 00:52:17 +0800 Subject: [PATCH 3/3] fix: scan all bootstrap version witnesses with BusyBox --- .../releasecheck/bootstrap_commit_test.go | 79 +++++++++++++++---- internal/selfmanage/selfmanage_test.go | 6 +- scripts/install.sh | 28 +++++-- 3 files changed, 91 insertions(+), 22 deletions(-) diff --git a/internal/releasecheck/bootstrap_commit_test.go b/internal/releasecheck/bootstrap_commit_test.go index c2684e0..535dd68 100644 --- a/internal/releasecheck/bootstrap_commit_test.go +++ b/internal/releasecheck/bootstrap_commit_test.go @@ -25,7 +25,9 @@ func bootstrapCommitFunctions(t *testing.T) string { if start < 0 || end <= start { t.Fatal("missing bootstrap commit functions") } - return source[start:end] + return `MAX_BINARY_BYTES=67108864 +FSIZE_BLOCK_BYTES=$(ulimit -f 1; awk '$1 == "Max" && $2 == "file" && $3 == "size" { print $4 }' /proc/self/limits) +` + source[start:end] } func bootstrapShells(t *testing.T) [][]string { @@ -46,30 +48,75 @@ func bootstrapShells(t *testing.T) [][]string { } func TestBootstrapVersionWitnessAcrossShells(t *testing.T) { + utilities := []struct{ name, setup string }{{name: "default"}} + if busybox, err := exec.LookPath("busybox"); err == nil { + utilities = append(utilities, struct{ name, setup string }{ + name: "busybox-applets", + setup: fmt.Sprintf("grep() { %q grep \"$@\"; }; awk() { %q awk \"$@\"; }; tr() { %q tr \"$@\"; }\n", busybox, busybox, busybox), + }) + } for _, shell := range bootstrapShells(t) { - for _, tc := range []struct { - name, binary, want string - invalid bool - }{ - {"canonical", "\x00LTA_RELEASE_VERSION_V1{2.10.8}\x00", "2.10.8", false}, - {"prerelease", "LTA_RELEASE_VERSION_V1{2.10.8-rc.12}", "2.10.8-rc.12", false}, - {"absent", "old binary", "", false}, - {"noncanonical", "LTA_RELEASE_VERSION_V1{02.10.8}", "", false}, - {"overlong", "LTA_RELEASE_VERSION_V1{2.10.8-" + strings.Repeat("x", 128) + "}", "", false}, - {"duplicate", "LTA_RELEASE_VERSION_V1{2.10.8}\x00LTA_RELEASE_VERSION_V1{2.10.8}", "", true}, + for _, utility := range utilities { + for _, tc := range []struct { + name, binary, want string + invalid bool + }{ + {"canonical", "\x00LTA_RELEASE_VERSION_V1{2.10.8}\x00", "2.10.8", false}, + {"prerelease", "LTA_RELEASE_VERSION_V1{2.10.8-rc.12}", "2.10.8-rc.12", false}, + {"absent", "old binary", "", false}, + {"noncanonical", "LTA_RELEASE_VERSION_V1{02.10.8}", "", false}, + {"overlong", "LTA_RELEASE_VERSION_V1{2.10.8-" + strings.Repeat("x", 128) + "}", "", false}, + {"duplicate", "LTA_RELEASE_VERSION_V1{2.10.8}\x00LTA_RELEASE_VERSION_V1{2.10.8}", "", true}, + {"different after nul", "LTA_RELEASE_VERSION_V1{2.10.8}\x00LTA_RELEASE_VERSION_V1{2.10.7}", "", true}, + {"duplicate same line", "LTA_RELEASE_VERSION_V1{2.10.8} LTA_RELEASE_VERSION_V1{2.10.8}", "", true}, + {"different same line", "LTA_RELEASE_VERSION_V1{2.10.8} LTA_RELEASE_VERSION_V1{2.10.7}", "", true}, + {"duplicate newline", "LTA_RELEASE_VERSION_V1{2.10.8}\nLTA_RELEASE_VERSION_V1{2.10.8}", "", true}, + {"nul cannot join marker", "LTA_RELEASE_VERSION_V1{2.10.\x008}", "", false}, + {"nul cannot join prefix", "LTA_RELEASE_VER\x00SION_V1{2.10.8}", "", false}, + } { + t.Run(filepath.Base(shell[0])+"/"+utility.name+"/"+tc.name, func(t *testing.T) { + dir := t.TempDir() + binary := filepath.Join(dir, "binary") + if err := os.WriteFile(binary, []byte(tc.binary), 0o600); err != nil { + t.Fatal(err) + } + body := "set -eu\nMAX_RELEASE_VERSION_BYTES=128\n" + bootstrapCommitFunctions(t) + utility.setup + "\nread_release_version \"$TEST_BINARY\"\n" + cmd := exec.Command(shell[0], append(append([]string{}, shell[1:]...), "-c", body)...) + cmd.Env = append(os.Environ(), "tmp="+dir, "TEST_BINARY="+binary) + out, err := cmd.CombinedOutput() + if (err != nil) != tc.invalid || (!tc.invalid && string(out) != tc.want) { + t.Fatalf("version=%q error=%v want %q invalid=%v", out, err, tc.want, tc.invalid) + } + }) + } + } + } +} + +func TestBootstrapVersionWitnessToolFailures(t *testing.T) { + for _, shell := range bootstrapShells(t) { + for _, tc := range []struct{ name, setup string }{ + {"tr fails", `tr() { return 1; }`}, + {"tr partial failure", `tr() { printf 'LTA_RELEASE_VERSION_V1{2.10.8}\n'; return 1; }`}, + {"tr output exceeds exact cap", `MAX_BINARY_BYTES=64; tr() { printf 'LTA_RELEASE_VERSION_V1{2.10.8}\n%200s' x; }`}, + {"tr output exceeds kernel cap", `MAX_BINARY_BYTES=1024; tr() { printf 'LTA_RELEASE_VERSION_V1{2.10.8}\n%9000s' x; }`}, + {"grep fails", `grep() { return 2; }`}, + {"grep partial failure", `grep() { printf 'LTA_RELEASE_VERSION_V1{2.10.8}\n'; return 2; }`}, + {"grep no-match with output", `grep() { printf 'LTA_RELEASE_VERSION_V1{2.10.8}\n'; return 1; }`}, + {"awk fails", `awk() { return 2; }`}, + {"wc fails", `wc() { return 1; }`}, } { t.Run(filepath.Base(shell[0])+"/"+tc.name, func(t *testing.T) { dir := t.TempDir() binary := filepath.Join(dir, "binary") - if err := os.WriteFile(binary, []byte(tc.binary), 0o600); err != nil { + if err := os.WriteFile(binary, []byte("LTA_RELEASE_VERSION_V1{2.10.8}"), 0o600); err != nil { t.Fatal(err) } - body := "set -eu\nMAX_RELEASE_VERSION_BYTES=128\n" + bootstrapCommitFunctions(t) + "\nread_release_version \"$TEST_BINARY\"\n" + body := "set -eu\nulimit -c 0\nMAX_RELEASE_VERSION_BYTES=128\n" + bootstrapCommitFunctions(t) + tc.setup + "\nread_release_version \"$TEST_BINARY\"\n" cmd := exec.Command(shell[0], append(append([]string{}, shell[1:]...), "-c", body)...) cmd.Env = append(os.Environ(), "tmp="+dir, "TEST_BINARY="+binary) - out, err := cmd.CombinedOutput() - if (err != nil) != tc.invalid || (!tc.invalid && string(out) != tc.want) { - t.Fatalf("version=%q error=%v want %q invalid=%v", out, err, tc.want, tc.invalid) + if out, err := cmd.CombinedOutput(); err == nil { + t.Fatalf("accepted a failed or oversized witness scan: %s", out) } }) } diff --git a/internal/selfmanage/selfmanage_test.go b/internal/selfmanage/selfmanage_test.go index 420cf0a..2920fe6 100644 --- a/internal/selfmanage/selfmanage_test.go +++ b/internal/selfmanage/selfmanage_test.go @@ -344,7 +344,11 @@ func TestReleaseVersionWitnessIsCanonicalAndUnique(t *testing.T) { if shell == "busybox" { args = append(args, "ash") } - body := "set -eu\nMAX_RELEASE_VERSION_BYTES=128\n" + shellReader + "\nread_release_version \"$TEST_BINARY\"" + body := `set -eu +MAX_RELEASE_VERSION_BYTES=128 +MAX_BINARY_BYTES=67108864 +FSIZE_BLOCK_BYTES=$(ulimit -f 1; awk '$1 == "Max" && $2 == "file" && $3 == "size" { print $4 }' /proc/self/limits) +` + shellReader + "\nread_release_version \"$TEST_BINARY\"" cmd := exec.Command(path, append(args, "-c", body)...) cmd.Env = append(os.Environ(), "tmp="+dir, "TEST_BINARY="+binary) out, shellErr := cmd.CombinedOutput() diff --git a/scripts/install.sh b/scripts/install.sh index 0605a94..7309c1b 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -12,7 +12,7 @@ esac set -eu # Some systems link /bin/sh to Bash, which imports exported functions before # executing this file. Clear every command name we call before trusting PATH. -for imported_name in id uname mktemp stat sha256sum openssl timeout awk grep od wc cp chmod chown mv \ +for imported_name in id uname mktemp stat sha256sum openssl timeout awk grep tr od wc cp chmod chown mv \ dirname mkdir rm sleep sync flock curl getent nslookup command printf ulimit echo umask export unset set trap exit return break ':' '['; do unset -f "$imported_name" 2>/dev/null || : done @@ -58,7 +58,7 @@ case "$DEST" in *) fail "DEST must be an absolute path" ;; esac -for command_name in id uname mktemp stat sha256sum openssl timeout awk grep od wc cp chmod chown mv \ +for command_name in id uname mktemp stat sha256sum openssl timeout awk grep tr od wc cp chmod chown mv \ dirname mkdir rm sleep sync flock curl; do command -v "$command_name" >/dev/null 2>&1 || fail "required command not found: $command_name" done @@ -905,10 +905,28 @@ sync_destination_directory_chain() { # This witness is part of the Ed25519-authenticated candidate bytes. Read it # without running either binary; the unsigned mirror index is only a selector. read_release_version() { + # BusyBox grep can stop matching a binary line at its first NUL even with -a. + # Convert NUL to a separator without joining bytes into a new valid witness. + # Keep each stage separate: a pipeline would hide tr failures in POSIX sh. + witness_blocks=$(( (MAX_BINARY_BYTES + FSIZE_BLOCK_BYTES - 1) / FSIZE_BLOCK_BYTES )) + if ! ( + ulimit -f "$witness_blocks" || exit 1 + tr '\000' '\n' < "$1" > "$tmp/witness-input" + ); then + return 1 + fi + witness_bytes=$(wc -c < "$tmp/witness-input") || return 1 + [ "$witness_bytes" -le "$MAX_BINARY_BYTES" ] || return 1 witness_status=0 - grep -aoE 'LTA_RELEASE_VERSION_V1[{](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?[}]' "$1" \ - > "$tmp/witnesses" || witness_status=$? - [ "$witness_status" -le 1 ] || return 1 + ( + ulimit -f "$witness_blocks" || exit 2 + grep -aoE 'LTA_RELEASE_VERSION_V1[{](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?[}]' "$tmp/witness-input" + ) > "$tmp/witnesses" || witness_status=$? + case "$witness_status" in + 0) ;; + 1) [ ! -s "$tmp/witnesses" ] || return 1 ;; + *) return 1 ;; + esac awk -v max="$MAX_RELEASE_VERSION_BYTES" ' { value=substr($0, 24, length($0)-24); if (length(value) <= max) { count++; version=value } } END { if (count > 1) exit 1; if (count == 1) printf "%s", version }