diff --git a/.github/workflows/mail-backend.yml b/.github/workflows/mail-backend.yml new file mode 100644 index 00000000..8b95b5de --- /dev/null +++ b/.github/workflows/mail-backend.yml @@ -0,0 +1,140 @@ +name: Mail backend + +on: + push: + branches: ["release/mail-*"] + paths: + - "plugins/omamail/src/**" + - "plugins/omamail/Cargo.*" + - "plugins/omamail/scripts/**" + - "plugins/omamail/tests/**" + - "tools/mail-backend-release.py" + - ".github/workflows/mail-backend.yml" + +permissions: + contents: read + +concurrency: + group: mail-backend-publication + cancel-in-progress: false + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + runner: ubuntu-24.04 + - arch: aarch64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 40 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 + with: + toolchain: "1.97.1" + - name: Prepare native musl tools + env: + ARCH: ${{ matrix.arch }} + run: | + set -euo pipefail + test "${GITHUB_REF_NAME}" = "release/mail-$(python3 tools/mail-backend-release.py version)" + sudo apt-get update + sudo apt-get install -y musl-tools nodejs + rustup target add "$ARCH-unknown-linux-musl" + - name: Test and build locked source + working-directory: plugins/omamail + env: + CARGO_BUILD_TARGET: ${{ matrix.arch }}-unknown-linux-musl + RUSTFLAGS: -C linker=musl-gcc + run: | + set -euo pipefail + cargo test --locked --features integration-test-credentials + cargo build --release --locked --bin omamail + OMAMAIL_TEST_BIN="$PWD/target/$CARGO_BUILD_TARGET/release/omamail" python3 tests/test_agent_native_bridge.py + - name: Verify exact native binary and create evidence + env: + ARCH: ${{ matrix.arch }} + run: | + python3 tools/mail-backend-release.py prepare \ + "plugins/omamail/target/$ARCH-unknown-linux-musl/release/omamail" "$ARCH" artifact + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: mail-${{ matrix.arch }} + path: artifact/* + if-no-files-found: error + retention-days: 1 + + publish: + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: mail-* + path: artifacts + - name: Assemble matching native builds + run: python3 tools/mail-backend-release.py assemble artifacts release-assets + - name: Publish a new backend-only prerelease + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + version="$(python3 tools/mail-backend-release.py version)" + tag="mail-backend-$version" + test "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$GITHUB_REF_NAME" --jq .object.sha)" = "$GITHUB_SHA" + # Successful listing is required; authentication/network errors never mean absent. + gh api --paginate "repos/$GITHUB_REPOSITORY/releases?per_page=100" --jq '.[].tag_name' > existing-releases + gh api --paginate "repos/$GITHUB_REPOSITORY/git/matching-refs/tags/$tag" --jq '.[].ref' > existing-tags + if grep -Fxq "$tag" existing-releases || grep -Fxq "refs/tags/$tag" existing-tags; then + echo 'Version already exists; never overwrite release assets.' >&2 + exit 1 + fi + cat > release-notes.md <<'NOTES' + Backend-only nbshell maintenance build. This is not a shell update. + + - Builds the bundled Mail source with Rustls 0.23.45 (RUSTSEC-2026-0285 fix). + - Native static Linux x86_64 and aarch64 binaries; existing source API 5. + - Source fingerprints, exact compiler/architecture evidence and archive checksums included. + - The nbshell plugin pin is updated separately, only after public-asset verification. + + This build is maintained by nbshell, not an upstream Omamail release. + NOTES + gh release create "$tag" release-assets/* --target "$GITHUB_SHA" \ + --title "Mail backend $version (nbshell rebuild)" --notes-file release-notes.md \ + --prerelease --latest=false + mkdir published + gh release download "$tag" --dir published + diff -r release-assets published + + verify-published: + needs: publish + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + runner: ubuntu-24.04 + - arch: aarch64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Download and exercise the public binary natively + env: + GH_TOKEN: ${{ github.token }} + ARCH: ${{ matrix.arch }} + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y nodejs + mkdir published + gh release download "mail-backend-$(python3 tools/mail-backend-release.py version)" --dir published + python3 tools/mail-backend-release.py verify published "$ARCH" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b32a235..6bd3fe8b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,8 @@ jobs: id-token: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 - name: Verify tag and release metadata run: | @@ -102,11 +104,14 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | + set -euo pipefail prerelease=() [[ "$(cat VERSION)" == *-* ]] && prerelease+=(--prerelease) version="$(cat VERSION)" + # Backend-only tags must not become the baseline for shell release notes. + previous="$(git describe --tags --abbrev=0 --match 'v[0-9]*' "${GITHUB_SHA}^")" gh release create "${GITHUB_REF_NAME}" nbshell-manual.zip \ "nbshell-${version}.tar.gz" "nbshell-${version}.tar.gz.sha256" \ "nbshell-${version}.tar.gz.sigstore.json" \ --title "nbshell $(cat VERSION)" \ - --generate-notes "${prerelease[@]}" + --generate-notes --notes-start-tag "$previous" "${prerelease[@]}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 88f28dc0..416be7f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,10 @@ configuration and plugin interfaces before `1.0.0`. ### Fixed +- Mail now pins the verified nbshell backend rebuild with Rustls 0.23.45, + closing RUSTSEC-2026-0285 in the delivered executable, not only its source. + Both Linux architectures are tested after publication; archive hashes are + anchored in the shell release and failed installs preserve the prior runtime. - Project status no longer executes configured Git content filters or inherits Git environment overrides. Output is capped and timeout/cancellation cleans up the complete command process group. diff --git a/docs/audits/code-review-2026-09-17.md b/docs/audits/code-review-2026-09-17.md index a1cfebfa..10e4a77d 100644 --- a/docs/audits/code-review-2026-09-17.md +++ b/docs/audits/code-review-2026-09-17.md @@ -68,12 +68,22 @@ The Rustls advisory describes accepting handshake messages at an incorrect encryption level, not an established network-attacker authentication bypass. Nevertheless, the currently pinned upstream Mail 0.10.4 binary cannot be claimed fixed by changing our source lockfile. Upstream 0.10.5 still locks Rustls 0.23.44. -Beta publication remains blocked pending a verified corrected backend delivery -path; no pin, tag or release is claimed to have been published here. - -Security verdict: **BLOCK for beta publication on the unresolved Mail binary -boundary**; the Git and installer fixes have local regression evidence. No -claim of blanket shell or third-party security certification is made. +This initial blocker is resolved by the separately published +[nbshell backend rebuild 0.10.4-nbshell.1](https://github.com/nerdislb/nbshell/releases/tag/mail-backend-0.10.4-nbshell.1). +Both static Linux architectures passed native Rust/agent/API checks, followed +by public-download API and real-installer verification. The shell now pins +those verified archive hashes and API 5, already implemented by the bundled +source. The actual downloaded x86_64 binary also passes the production +Quickshell process test and 22 synthetic native-agent checks locally. The +legacy-adoption case uses the supported historical runtime/bin layout; the +installed-plugin legacy-job guard is retained, not bypassed. + +Security verdict: **PASS for the corrected Mail binary delivery boundary** +after the public-asset checks above; the Git and installer fixes have local +regression evidence. No claim of blanket shell or third-party security +certification is made. The independent-review and hardware/real-account limits +below still apply. The [maintenance plan](../mail-backend-maintenance.md) +records the remaining dependency follow-up and release/rollback rules. Independent-provider review was attempted within existing subscriptions: Claude Fable returned quota exhaustion; Claude Sonnet timed out; Gemini @@ -88,8 +98,21 @@ over literal source snippets where behavior can be exercised. Keep helper resource bounds close to their implementation; avoid a large generic framework or global UI rewrite without a demonstrated need. -The initial complete gate failed on the stale contracts described above. Focused -reruns cover their corrections; final clean-candidate verification and published -artifact validation remain separate release steps. No new two-hour soak, +The initial complete gate failed on the stale contracts described above. +After correction, the complete local gate and GitHub Validate passed on +1750e54; the backend-delivery follow-up repeats the affected tests and complete +CI before merging. Final shell-tag signature and archive checks remain a +separate step from the completed backend publication checks. No new two-hour soak, physical suspend/display matrix, second-machine login/onboarding, real-account Mail/Gaming acceptance or complete AT-SPI certification is claimed. + + +### Follow-up CI timer regression + +The final PR gate exposed a flaky recovery-notice fixture: a fixed 4.2-second +wait assumed a 4-second QML toast timer had already fired. On one busy runner, +the old toast was still present when the recovery warning cleared. The fixture +now waits for the actual `draftSavedToast` transition with a bounded deadline, +then asserts that the recovery warning survived that transition. No UI timer +or production behavior changed. Five focused repetitions and all 76 tests in +that component pass; the full remote gate is repeated on the corrected commit. diff --git a/docs/mail-backend-maintenance.md b/docs/mail-backend-maintenance.md new file mode 100644 index 00000000..51238435 --- /dev/null +++ b/docs/mail-backend-maintenance.md @@ -0,0 +1,45 @@ +# Mail backend maintenance + +## What nbshell owns + +nbshell maintains a **backend-only rebuild** of its bundled Omamail source. This is not an upstream Omamail release and does not include upstream's standalone desktop apps. Preserve upstream authorship and licenses. Do not describe the whole vendored plugin as a one-line fork: nbshell already carries integration and presentation changes, and its bundled source implements API 5 while the previous downloaded backend provided API 4. + +The initial rebuild is `0.10.4-nbshell.1`. Its additional dependency fix updates Rustls from 0.23.44 to 0.23.45 for [RUSTSEC-2026-0285](https://rustsec.org/advisories/RUSTSEC-2026-0285.html). No new mail feature is added for this rebuild. The complete source commit, source-file fingerprints, compiler version, architecture and binary hashes travel with each release. A version label alone is not provenance. + +The maintainer is the nbshell repository owner. An agent can inspect, prepare and test changes; successful tests are evidence, not permission to silently widen the fork, enable paid services or change accounts. Public replies to upstream maintainers require explicit authorization. + +## Small, regular maintenance loop + +| When | Work | Outcome | +|---|---|---| +| Weekly | Inspect upstream releases, RustSec/OSV advisories for the locked Cargo graph, and changes to the release toolchain/actions. | A dated assessment: no action, candidate update, or security work. | +| Before each nbshell beta | Refresh advisory checks; verify the exact backend pin and both delivered architectures; run compatibility and installation/rollback gates. | Explicit PASS, BLOCK or NOT VERIFIED for the affected security boundary. | +| On a relevant security advisory | Check affected versions, enabled features and actual call paths promptly; prepare the smallest effective fix. | Patch/release priority based on reachability and impact, not merely scanner severity. | +| On a regression | Preserve the last working release and user data; diagnose with synthetic fixtures. | A new immutable corrective version, never silently replaced assets. | +| Monthly or at an upstream replacement candidate | Review whether our rebuild is still necessary. | Keep a justified patch or retire the extra delivery path. | + +This is a maintenance policy, not a claim that monitoring is already scheduled. No autonomous dependency merging or publishing is enabled by this document. If a scheduled check is later enabled, it should report findings and never install or publish by itself. + +Budget: use the existing public repository and standard GitHub-hosted Linux runners only. No larger runners, paid fallback routes, extra storage purchases or automatic top-ups. Build artifacts have one-day retention and no Rust build cache is uploaded. Stop at existing limits. See [GitHub Actions billing](https://docs.github.com/en/billing/concepts/product-billing/github-actions). + +## Release sequence + +1. Start a clean `release/mail-X.Y.Z-nbshell.N` branch. Increment `N` for every new rebuild; never reuse a published version. Keep the Cargo package and lockfile versions identical. The UI manifest and standalone base metadata are independent; only the backend is built here. +2. Review the diff from the previous release. Record why each extra change is needed. Keep the dependency update narrow; do not update the entire Cargo graph merely to clear an unrelated warning. +3. Push the reviewed build change. `.github/workflows/mail-backend.yml` builds locked static musl executables on native x86_64 and ARM64 standard runners. It runs Rust tests, native agent tests and the actual backend API contract, checks ELF architecture/static linkage and private build paths, and records source fingerprints. Both architectures must agree on source/API inputs. +4. CI publishes a uniquely named `mail-backend-X.Y.Z-nbshell.N` prerelease in `nerdislb/nbshell`, not a shell release. It refuses an existing tag/release, compares the downloaded public bytes to the build outputs, and executes the public API contract natively on both architectures. Failure does not update the plugin pin. +5. Verify from a clean checkout/export of the release commit: generated Python bytecode in `src/` is not a release input and a development tree can have a different fingerprint even when Git reports clean. Only after those jobs pass, update `backend-version`, fold the existing API step into `releasedApiVersion`, and write `backend-release.json` with both **downloaded and verified** archive hashes. The consumer uses a fixed nbshell release origin; metadata cannot supply another URL. The source-anchored archive hash must agree with the remote sidecar before any candidate executable runs. There is no upstream/PATH/latest fallback for an nbshell rebuild. +6. Test the real installer in an isolated profile, including corrupt delivery and preservation of the previous executable. Run the production Quickshell bridge against the downloaded binary. Existing legacy AI jobs must finish or be cancelled by their owner before an upgrade; retain `check-upgrade.py`. Source-build legacy adoption is not automatically evidence for binaries built elsewhere. +7. Run the full shell release gate, merge the reviewed branch, install the candidate and verify the live runtime without changing configuration or account data. Publish the nbshell beta using the normal signed shell-archive workflow. Its trusted plugin hash pins are part of that archive. + +Backend binaries remain separate assets: the shell source archive is already close to the updater's 50 MiB limit. Never append executables to it without reviewing the real consumer limits. + +## Recovery and retirement + +- Failed download, checksum, version or archive validation must preserve the installed executable. Never remove mail accounts, drafts, caches or keyring entries as a recovery shortcut. +- Existing tags and assets remain available for older plugin revisions. A bad published candidate gets a new version; do not overwrite or delete it to disguise the failure. Do not move the shell pin to it. +- A rollback to an older backend is allowed only after checking API/data compatibility and security. The vulnerable pre-fix binary is not our recommended security rollback. Prefer a new corrective build when a rollback would reintroduce a known issue. +- Return to upstream only when its **actual published binaries**, not just its source lockfile, contain the required fix and satisfy the current plugin contract. Verify both architectures, the update transition, and the existing QML feature guards. Keep old nbshell artifacts for reproducibility after the return. +- Open findings remain explicit: the current Hickory record-encoding advisory has no established application-level reproducer in the reviewed resolver use; it is a maintenance item, not a clean raw scan. Periodic review must revisit that assessment if dependencies, features or callers change. + +See the [review report](audits/code-review-2026-09-17.md) and [shell release process](releasing.md). diff --git a/docs/releasing.md b/docs/releasing.md index f9114c06..02d5612d 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -6,6 +6,8 @@ change before version 1.0. ## Prepare a release +For the separately delivered Mail executable, follow the [backend maintenance and release policy](mail-backend-maintenance.md). A source dependency fix is not a fix to the user's downloaded binary. + 1. Update `VERSION` and move the relevant entries from `Unreleased` in `CHANGELOG.md` to a dated version section. 2. Run the complete local gate: @@ -14,7 +16,7 @@ change before version 1.0. bash tests/release-gate.sh mkdocs build --strict git diff --check - git diff --check "$(git describe --tags --abbrev=0)"..HEAD + git diff --check "$(git describe --tags --abbrev=0 --match 'v[0-9]*')"..HEAD ``` Before a release, also run a current Python advisory scan in an isolated @@ -59,6 +61,12 @@ the release workflow identity at the exact tag, then verifies the checksum. It refuses installation when any asset or verification step is missing. Do not tag a commit until its live desktop test has passed. +Shell release notes start at the previous ancestor tag matching `v[0-9]*`. +Backend-only `mail-backend-*` releases are not shell releases and must never +become that baseline. The release job checks out the complete history for this +selection; a missing previous shell tag fails closed rather than silently +generating an incomplete change list. + ## After publishing - Verify the release archive, checksum, dashboard update check, and installation diff --git a/plugins/omamail/AGENTS.md b/plugins/omamail/AGENTS.md index 6c3109a8..0510ccc7 100644 --- a/plugins/omamail/AGENTS.md +++ b/plugins/omamail/AGENTS.md @@ -562,6 +562,8 @@ key. What matters while working: ## Releasing +The rules below describe the original upstream standalone repository. In the nbshell monorepo, the explicitly authorized **backend-only rebuild** uses the repository-root `.github/workflows/mail-backend.yml` and `docs/mail-backend-maintenance.md`: `release/mail-X.Y.Z-nbshell.N` branch, both native Linux architectures, a unique `mail-backend-X.Y.Z-nbshell.N` prerelease, public-byte verification, then a separate reviewed pin update. Do not invoke upstream's standalone publish command here. Preserve the no-overwrite, exact-pin, API and security gates; never update the pin before the backend publication succeeds. + - `make publish VERSION=X.Y.Z` creates `release/X.Y.Z` and one PR from a clean, synchronized main; without VERSION it increments the patch. It prepares version metadata and pushes only the release branch. Never push main directly or bypass its PR requirement. - Release CI accepts only the matching versioned release branch. It builds both native backends, creates the tag, publishes and verifies public assets, then updates `backend-version` and folds `backend-api.json` on that same branch. Merge that PR once after the pin commit passes the required backend gate. Never update the QML backend pin before its release succeeds. - Pin-only pushes exclude both `backend-version` and `backend-api.json` from the Release trigger. Existing tags and releases are never overwritten; failures leave the pin unchanged. See `docs/BACKEND-RUNTIME.md` for recovery. diff --git a/plugins/omamail/Cargo.lock b/plugins/omamail/Cargo.lock index f16471a4..765202be 100644 --- a/plugins/omamail/Cargo.lock +++ b/plugins/omamail/Cargo.lock @@ -1309,7 +1309,7 @@ dependencies = [ [[package]] name = "omamail" -version = "0.10.4" +version = "0.10.4-nbshell.1" dependencies = [ "base64", "chrono", diff --git a/plugins/omamail/Cargo.toml b/plugins/omamail/Cargo.toml index 16ccdb0c..b3fbdef7 100644 --- a/plugins/omamail/Cargo.toml +++ b/plugins/omamail/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "omamail" -version = "0.10.4" +version = "0.10.4-nbshell.1" edition = "2024" publish = false diff --git a/plugins/omamail/backend-api.json b/plugins/omamail/backend-api.json index 340d0a28..914361c2 100644 --- a/plugins/omamail/backend-api.json +++ b/plugins/omamail/backend-api.json @@ -1,6 +1,6 @@ { "apiVersion": 5, - "releasedApiVersion": 4, + "releasedApiVersion": 5, "protocolVersion": 1, "methods": [ "system.info", @@ -187,11 +187,18 @@ "name": "iCloud request refuses an external credential origin", "method": "calendar.request", "params": { - "source": {"kind": "icloud", "accountId": "imap:missing@icloud.com", "url": "https://example.org/calendar/"}, - "operation": "list", "body": "report" + "source": { + "kind": "icloud", + "accountId": "imap:missing@icloud.com", + "url": "https://example.org/calendar/" + }, + "operation": "list", + "body": "report" }, "errorCode": -32000, - "equals": {"message": "calendar_origin_refused"} + "equals": { + "message": "calendar_origin_refused" + } }, { "name": "strict calendar discovery parameters", @@ -722,21 +729,40 @@ { "name": "gmail message mutation answers with a queue ticket", "method": "gmail.trash", - "params": {"accountId": "contract@example.org", "id": "contract-message"}, - "equals": {"queued": true}, - "types": {"ticket": "string"} + "params": { + "accountId": "contract@example.org", + "id": "contract-message" + }, + "equals": { + "queued": true + }, + "types": { + "ticket": "string" + } }, { "name": "gmail batch mutation answers with a queue ticket", "method": "gmail.batchModify", - "params": {"accountId": "contract@example.org", "ids": ["contract-message"], "addLabelIds": ["STARRED"], "removeLabelIds": []}, - "equals": {"queued": true}, - "types": {"ticket": "string"} + "params": { + "accountId": "contract@example.org", + "ids": [ + "contract-message" + ], + "addLabelIds": [ + "STARRED" + ], + "removeLabelIds": [] + }, + "equals": { + "queued": true + }, + "types": { + "ticket": "string" + } } ], "unreleased": { - "methods": ["outlook.connectionCheck", "calendar.discover"], - "cases": ["missing Outlook connection", "strict calendar discovery parameters", "iCloud request refuses an external credential origin", - "gmail message mutation answers with a queue ticket", "gmail batch mutation answers with a queue ticket"] + "methods": [], + "cases": [] } } diff --git a/plugins/omamail/backend-release.json b/plugins/omamail/backend-release.json new file mode 100644 index 00000000..6fe6835c --- /dev/null +++ b/plugins/omamail/backend-release.json @@ -0,0 +1,8 @@ +{ + "schemaVersion": 1, + "version": "0.10.4-nbshell.1", + "archives": { + "x86_64": "706f88f9940e041d9e17373a969ad5fb3732077ff54685a23a9e3d9ecdd34fa1", + "aarch64": "1f860746b7c0ed58ca4ee2709d7b7bf6ae6c45e0b1e1486a1ab91ce7113c6862" + } +} diff --git a/plugins/omamail/backend-version b/plugins/omamail/backend-version index 9b40aa6c..5bd7737c 100644 --- a/plugins/omamail/backend-version +++ b/plugins/omamail/backend-version @@ -1 +1 @@ -0.10.4 +0.10.4-nbshell.1 diff --git a/plugins/omamail/docs/BACKEND-RUNTIME.md b/plugins/omamail/docs/BACKEND-RUNTIME.md index a17b9f02..4cd74a3d 100644 --- a/plugins/omamail/docs/BACKEND-RUNTIME.md +++ b/plugins/omamail/docs/BACKEND-RUNTIME.md @@ -1,5 +1,9 @@ # Backend runtimes and releases +## nbshell backend-only rebuild + +The bundled nbshell plugin uses `0.10.4-nbshell.1` from the fixed `nerdislb/nbshell` release origin. `backend-release.json` anchors both archive hashes in the shell source; the runtime refuses a changed archive even if its remote checksum is also changed. The original upstream flow below remains applicable only to historical upstream pins. See the [nbshell maintenance policy](../../../docs/mail-backend-maintenance.md) for native build evidence, API compatibility, publication ordering and the return-to-upstream criteria. The exact pin and explicit-install requirements remain unchanged. + ## Omarchy plugin-owned backend Omarchy's Plugin Marketplace owns the checkout and its UI. Omamail keeps exactly diff --git a/plugins/omamail/scripts/backend-runtime.py b/plugins/omamail/scripts/backend-runtime.py index 8e1f79b6..26c65b39 100755 --- a/plugins/omamail/scripts/backend-runtime.py +++ b/plugins/omamail/scripts/backend-runtime.py @@ -244,11 +244,44 @@ def locked(): os.close(descriptor) +def release_pin(required, architecture): + """nbshell rebuilds have a fixed publisher and hashes anchored in the shell source. + + Never fall back to upstream for a rebuild, nor accept an arbitrary URL from + metadata. Historical upstream pins keep their original release policy. + """ + if "-nbshell." not in required: + return "https://github.com/huacnlee/omamail/releases/download/v" + required + "/", None + require(re.fullmatch(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)-nbshell\.[1-9][0-9]*", required), + "Invalid nbshell backend version.") + path = ROOT / "backend-release.json" + safe_path(path) + with path.open("rb") as source: + raw = source.read(4097) + require(len(raw) <= 4096, "Backend release pin is too large.") + def unique(pairs): + result = {} + for key, value in pairs: + require(key not in result, "Duplicate backend release field.") + result[key] = value + return result + value = json.loads(raw, object_pairs_hook=unique) + require(isinstance(value, dict) and set(value) == {"schemaVersion", "version", "archives"} + and type(value["schemaVersion"]) is int and value["schemaVersion"] == 1 + and value["version"] == required, "Backend release pin does not match.") + hashes = value["archives"] + require(isinstance(hashes, dict) and set(hashes) == {"x86_64", "aarch64"} + and all(isinstance(digest, str) and re.fullmatch(r"[0-9a-f]{64}", digest) + for digest in hashes.values()), "Invalid backend archive pins.") + require(architecture in hashes, "Unsupported backend architecture.") + return "https://github.com/nerdislb/nbshell/releases/download/mail-backend-" + required + "/", hashes[architecture] + + def install(required, architecture): safe_path(BINARY) safe_path(LOCAL_BUILD) asset = "omamail-linux-" + architecture + ".tar.gz" - base = "https://github.com/huacnlee/omamail/releases/download/v" + required + "/" + base, trusted_hash = release_pin(required, architecture) with deadline(): checksums = download(base + "SHA256SUMS", 64 * 1024).decode("ascii") entries = [] @@ -258,6 +291,8 @@ def install(required, architecture): if match[2] == asset: entries.append(match[1].lower()) require(len(entries) == 1, "Release checksum entry is missing or ambiguous.") + require(trusted_hash is None or entries[0] == trusted_hash, + "Release checksum differs from the trusted shell pin.") compressed = download(base + asset, ARCHIVE_LIMIT) require(hashlib.sha256(compressed).hexdigest() == entries[0], "Release checksum does not match.") safe_path(BINARY.parent, directory=True, create=True) @@ -284,6 +319,8 @@ def install(required, architecture): candidate.chmod(0o700) require(version_of(candidate) == required, "Downloaded backend has the wrong version.") require(pin() == required, "Backend version pin changed during installation.") + require(release_pin(required, architecture) == (base, trusted_hash), + "Backend release pin changed during installation.") replace_runtime(candidate) diff --git a/plugins/omamail/scripts/package-backend.py b/plugins/omamail/scripts/package-backend.py index 3d1939bb..7235a109 100644 --- a/plugins/omamail/scripts/package-backend.py +++ b/plugins/omamail/scripts/package-backend.py @@ -25,7 +25,7 @@ def check(root, tag=None, require_pin=False): version = tomllib.loads((root / 'Cargo.toml').read_text())['package']['version'] - if not re.fullmatch(r'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)', version): + if not re.fullmatch(r'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-nbshell\.[1-9][0-9]*)?', version): raise ValueError('expected canonical MAJOR.MINOR.PATCH') versions = {} packages = tomllib.loads((root / 'Cargo.lock').read_text())['package'] @@ -34,13 +34,22 @@ def check(root, tag=None, require_pin=False): if not isinstance(manifest, dict) or not isinstance(manifest.get('version'), str): raise ValueError('manifest.json requires a string version') versions['manifest'] = manifest['version'] - cmake = (root / 'app/CMakeLists.txt').read_text() - app_versions = re.findall( - r'^\s*project\s*\(\s*omamail-app\s+VERSION\s+([^\s\)]+)', cmake, - flags=re.MULTILINE | re.IGNORECASE) - if len(app_versions) != 1: - raise ValueError('app/CMakeLists.txt requires one omamail-app project version') - versions['app'] = app_versions[0] + app_project = root / 'app/CMakeLists.txt' + # nbshell vendors only the plugin, not upstream's standalone Qt host. + if app_project.exists() or '-nbshell.' not in version: + cmake = app_project.read_text() + app_versions = re.findall( + r'^\s*project\s*\(\s*omamail-app\s+VERSION\s+([^\s\)]+)', cmake, + flags=re.MULTILINE | re.IGNORECASE) + if len(app_versions) != 1: + raise ValueError('app/CMakeLists.txt requires one omamail-app project version') + versions['app'] = app_versions[0] + # The nbshell backend-only rebuild retains the upstream UI/app versions. + if '-nbshell.' in version: + base = version.split('-nbshell.', 1)[0] + if versions['manifest'] not in (base, version) or versions.get('app', base) != base: + raise ValueError('nbshell rebuild requires the matching upstream UI/app base') + versions['manifest'] = versions['app'] = version if require_pin: versions['backend-version'] = (root / 'backend-version').read_text().removesuffix('\n') if tag is not None: @@ -56,7 +65,7 @@ def pin_version(root): raise ValueError('backend-version must be a regular file') with path.open('rb') as stream: raw = stream.read(129) - if len(raw) > 128 or not re.fullmatch(rb'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\n?', raw): + if len(raw) > 128 or not re.fullmatch(rb'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-nbshell\.[1-9][0-9]*)?\n?', raw): raise ValueError('backend-version must be canonical MAJOR.MINOR.PATCH') return raw.decode('ascii').removesuffix('\n') diff --git a/plugins/omamail/tests/test_backend_process.py b/plugins/omamail/tests/test_backend_process.py index a43f7158..aa7d9f30 100644 --- a/plugins/omamail/tests/test_backend_process.py +++ b/plugins/omamail/tests/test_backend_process.py @@ -152,7 +152,7 @@ def main(): qs = shutil.which("qs") if not qs: raise SystemExit("Quickshell is required: install it, then rerun make test-backend-process") - binary = ROOT / "target/debug/omamail" + binary = Path(os.environ.get("OMAMAIL_TEST_BIN") or ROOT / "target/debug/omamail").resolve() version = tomllib.loads((ROOT / "Cargo.toml").read_text())["package"]["version"] with tempfile.TemporaryDirectory(prefix="omamail-backend-process-") as directory: temporary = Path(directory) diff --git a/plugins/omamail/tests/test_backend_release.py b/plugins/omamail/tests/test_backend_release.py index 86f8adfc..87a56467 100644 --- a/plugins/omamail/tests/test_backend_release.py +++ b/plugins/omamail/tests/test_backend_release.py @@ -246,6 +246,21 @@ def test_unreleased_error_message_expectations_preserve_published_contract(self) result = self.check_api('--published', self.published) self.assertEqual(result.returncode, 0, result.stderr) + def test_nbshell_rebuild_has_distinct_version_and_independent_ui_base(self): + self.metadata() + for filename in ('Cargo.toml', 'Cargo.lock'): + path = self.root / filename + path.write_text(path.read_text().replace('0.8.2', '0.8.2-nbshell.1')) + result = self.run_helper('check', '--root', self.root) + self.assertEqual(result.returncode, 0, result.stderr) + (self.root / 'app/CMakeLists.txt').unlink() # backend-only vendored tree + result = self.run_helper('check', '--root', self.root) + self.assertEqual(result.returncode, 0, result.stderr) + (self.root / 'backend-version').write_text('0.8.2-nbshell.1\n') + self.assertEqual(self.run_helper('pin-version', '--root', self.root).returncode, 0) + (self.root / 'manifest.json').write_text('{"version":"0.8.3"}') + self.assertNotEqual(self.run_helper('check', '--root', self.root).returncode, 0) + def test_api_revisions_and_pin_require_canonical_values(self): contract = self.api_fixture() for value in (True, 0, -1, '1', 1.5, 2147483648): diff --git a/plugins/omamail/tests/test_backend_runtime.py b/plugins/omamail/tests/test_backend_runtime.py index 76b09bd1..ae1b4f01 100644 --- a/plugins/omamail/tests/test_backend_runtime.py +++ b/plugins/omamail/tests/test_backend_runtime.py @@ -270,6 +270,71 @@ def test_missing_status_never_downloads(self): self.assertEqual(result, dict(state="missing", requiredVersion="0.8.2", requiredApiVersion=1, latestApiVersion=1, unreleasedMethods=[], installedVersion="", executable=str(self.binary), error="", cliInstalled=False)) self.assertFalse(self.binary.parent.exists()) + def test_nbshell_release_requires_source_anchored_hash_before_execution(self): + version = "0.8.2-nbshell.1" + (self.root / "backend-version").write_text(version + "\n") + archive = self.archive(version=version) + digest = hashlib.sha256(archive).hexdigest() + metadata = dict(schemaVersion=1, version=version, + archives=dict(x86_64=digest, aarch64="a" * 64)) + pin = self.root / "backend-release.json" + pin.write_text(json.dumps(metadata)) + requested = [] + def fetch(url, limit): + self.assertTrue(url.startswith("https://github.com/nerdislb/nbshell/releases/download/mail-backend-" + version + "/")) + requested.append(url) + return ((digest + " omamail-linux-x86_64.tar.gz\n").encode() + if url.endswith("SHA256SUMS") else archive) + with patch.object(self.manager, "download", side_effect=fetch): + self.assertEqual(self.manager.run("install")["state"], "ready") + old = self.binary.read_bytes() + # A changed remote archive AND matching remote checksum cannot replace it. + archive += b"changed" + digest = hashlib.sha256(archive).hexdigest() + with patch.object(self.manager, "version_of", side_effect=AssertionError("must not execute")): + result = self.manager.run("install") + self.assertEqual(result["state"], "error", result) + self.assertIn("trusted shell pin", result["error"]) + self.assertEqual(self.binary.read_bytes(), old) + self.assertEqual(len(requested), 3) # reject before the second archive download + + def test_nbshell_release_missing_malformed_or_redirected_pin_never_downloads(self): + version = "0.8.2-nbshell.1" + (self.root / "backend-version").write_text(version + "\n") + metadata = dict(schemaVersion=1, version=version, + archives=dict(x86_64="a" * 64, aarch64="b" * 64)) + pin = self.root / "backend-release.json" + self.assertEqual(self.manager.run("install")["state"], "error") + for value in (dict(metadata, version="0.8.2-nbshell.2"), + dict(metadata, schemaVersion=True), + dict(metadata, url="https://example.org/evil"), + dict(metadata, archives={"x86_64": "a" * 64})): + pin.write_text(json.dumps(value)) + self.assertEqual(self.manager.run("install")["state"], "error") + pin.write_text(json.dumps(metadata).replace('"schemaVersion": 1', '"schemaVersion": 1, "schemaVersion": 1')) + self.assertEqual(self.manager.run("install")["state"], "error") + + def test_nbshell_release_pin_race_preserves_previous_binary(self): + version = "0.8.2-nbshell.1" + (self.root / "backend-version").write_text(version + "\n") + archive = self.archive(version=version) + digest = hashlib.sha256(archive).hexdigest() + pin = self.root / "backend-release.json" + metadata = dict(schemaVersion=1, version=version, + archives=dict(x86_64=digest, aarch64="a" * 64)) + pin.write_text(json.dumps(metadata)) + self.binary.parent.mkdir(parents=True) + self.binary.write_bytes(b"previous") + def fetch(url, limit): + if url.endswith("SHA256SUMS"): + return (digest + " omamail-linux-x86_64.tar.gz\n").encode() + pin.write_text(json.dumps(dict(metadata, archives=dict(x86_64="f" * 64, aarch64="a" * 64)))) + return archive + with patch.object(self.manager, "download", side_effect=fetch): + result = self.manager.run("install") + self.assertEqual(result["state"], "error", result) + self.assertEqual(self.binary.read_bytes(), b"previous") + def test_release_status_uses_only_local_pin_and_api_despite_newer_cargo(self): self.local_checkout() self.release(self.archive()) diff --git a/plugins/omamail/ui/tests/qml/tst_app_compose_pending.qml b/plugins/omamail/ui/tests/qml/tst_app_compose_pending.qml index f4bff824..b31ec98f 100644 --- a/plugins/omamail/ui/tests/qml/tst_app_compose_pending.qml +++ b/plugins/omamail/ui/tests/qml/tst_app_compose_pending.qml @@ -382,7 +382,9 @@ Item { app.writeComposeRecovery(raw) var warning = app.draftSavedNotice verify(warning.indexOf("Keep this window open") >= 0) - wait(4200) + // Wait for the actual timer effect: a fixed 200 ms margin races the + // event loop on a busy CI runner and never proves the toast expired. + tryCompare(app, "draftSavedToast", "", 10000) compare(app.draftSavedNotice, warning, "the prior save's timer cannot dismiss a recovery warning") compare(recoveryBackend.requests.length, priorRequests, "the old connection receives no recovery RPC") verify(app.composeWriteQueued) diff --git a/tools/mail-backend-release.py b/tools/mail-backend-release.py new file mode 100644 index 00000000..62369137 --- /dev/null +++ b/tools/mail-backend-release.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Build evidence and verify nbshell's backend-only Mail release (no pin mutation).""" +import argparse +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import platform +import re +import shutil +import subprocess +import tarfile +import tempfile +import tomllib + +ROOT = Path(__file__).resolve().parents[1] +MAIL = ROOT / "plugins/omamail" +spec = importlib.util.spec_from_file_location("mail_packaging", MAIL / "scripts/package-backend.py") +packaging = importlib.util.module_from_spec(spec) +spec.loader.exec_module(packaging) +ARCHES = ("x86_64", "aarch64") + + +def run(*args, **kwargs): + return subprocess.check_output(args, text=True, **kwargs).strip() + + +def version(): + value = packaging.check(MAIL) + if not re.fullmatch(r"\d+\.\d+\.\d+-nbshell\.[1-9][0-9]*", value): + raise ValueError("expected a uniquely versioned nbshell rebuild") + return value + + +def verify_binary(binary, arch): + data = binary.read_bytes() + machine = {"x86_64": 62, "aarch64": 183}[arch] + if data[:6] != b"\x7fELF\x02\x01" or int.from_bytes(data[18:20], "little") != machine: + raise ValueError("backend ELF architecture mismatch") + if "INTERP" in run("readelf", "-l", str(binary)) or "NEEDED" in run("readelf", "-d", str(binary)): + raise ValueError("backend must be statically linked") + # Generic CI paths are public build context; personal workstation paths are not. + homes = re.findall(rb"/(?:home|Users)/([^/\x00\s]+)", data) + if any(name not in (b"runner",) for name in homes): + raise ValueError("backend contains a non-generic home path") + if run(str(binary), "--version") != "omamail " + version(): + raise ValueError("backend version mismatch") + run("python3", str(MAIL / "tests/test_backend_api.py"), "--binary", str(binary), + "--expected-version", version()) + + +def prepare(binary, arch, output): + if platform.machine() != arch: + raise ValueError("release proof must run natively on the target architecture") + verify_binary(binary, arch) + packages = tomllib.loads((MAIL / "Cargo.lock").read_text())["package"] + rustls = [p["version"] for p in packages if p["name"] == "rustls"] + if rustls != ["0.23.45"]: + raise ValueError("reassess the TLS advisory before changing the approved lock") + packaging.package(binary, arch, output) + contract = packaging.read_api(MAIL / "backend-api.json") + contract["releasedApiVersion"] = contract["apiVersion"] + contract["unreleased"] = {"methods": [], "cases": []} + (output / "backend-api.json").write_text(json.dumps(contract, indent=2) + "\n") + (output / "backend-build.json").write_text(json.dumps(packaging.provenance(MAIL), indent=2, sort_keys=True) + "\n") + evidence = dict(schemaVersion=1, version=version(), architecture=arch, + sourceCommit=run("git", "rev-parse", "HEAD", cwd=ROOT), + rustc=run("rustc", "--version"), target=arch + "-unknown-linux-musl", + binarySha256=hashlib.sha256(binary.read_bytes()).hexdigest(), + rustls=rustls[0], apiVersion=contract["apiVersion"], + workflow="mail-backend.yml", nativeContract="passed") + (output / f"build-{arch}.json").write_text(json.dumps(evidence, indent=2) + "\n") + + +def assemble(inputs, output): + output.mkdir(parents=True, exist_ok=False) + expected = set() + for arch in ARCHES: + source = inputs / ("mail-" + arch) + for name in (f"omamail-linux-{arch}.tar.gz", f"build-{arch}.json", "backend-api.json", "backend-build.json"): + path = source / name + if path.is_symlink() or not path.is_file() or path.stat().st_size > 25 * 1024 * 1024: + raise ValueError("missing or oversized build artifact") + content = path.read_bytes() + target = output / name + if target.exists() and target.read_bytes() != content: + raise ValueError("native builds disagree about source or API provenance") + target.write_bytes(content) + expected.add(name) + evidence = json.loads((source / f"build-{arch}.json").read_bytes()) + if (evidence["sourceCommit"] != run("git", "rev-parse", "HEAD", cwd=ROOT) + or evidence["version"] != version() or evidence["architecture"] != arch): + raise ValueError("native build evidence differs from release source") + (output / "SHA256SUMS").write_text("".join( + hashlib.sha256((output / name).read_bytes()).hexdigest() + " " + name + "\n" + for name in sorted(expected) if name.endswith(".tar.gz"))) + packaging.verify(output, ARCHES) + packaging.check_provenance(MAIL, output / "backend-build.json") + + +def verify(directory, arch): + packaging.verify(directory, ARCHES) + packaging.check_provenance(MAIL, directory / "backend-build.json") + contract = packaging.read_api(MAIL / "backend-api.json") + contract["releasedApiVersion"] = contract["apiVersion"] + contract["unreleased"] = {"methods": [], "cases": []} + if packaging.read_api(directory / "backend-api.json") != contract: + raise ValueError("published API differs from the tested source contract") + expected = {f"omamail-linux-{a}.tar.gz" for a in ARCHES} + checksums = {} + for line in (directory / "SHA256SUMS").read_text().splitlines(): + match = re.fullmatch(r"([0-9a-f]{64}) ([a-zA-Z0-9_.-]+)", line) + if not match or match[2] in checksums: + raise ValueError("malformed or duplicate release checksum") + checksums[match[2]] = match[1] + if set(checksums) != expected: + raise ValueError("release checksum set differs") + for name, digest in checksums.items(): + if hashlib.sha256((directory / name).read_bytes()).hexdigest() != digest: + raise ValueError("release metadata or archive hash mismatch") + with tempfile.TemporaryDirectory(prefix="mail-published-") as temp: + binary = Path(temp) / "omamail" + with tarfile.open(directory / f"omamail-linux-{arch}.tar.gz") as archive: + member, = archive.getmembers() + if member.name != "omamail" or not member.isfile() or member.size > 25 * 1024 * 1024: + raise ValueError("unexpected backend archive layout") + binary.write_bytes(archive.extractfile(member).read()) + binary.chmod(0o700) + evidence = json.loads((directory / f"build-{arch}.json").read_bytes()) + if (evidence["version"] != version() or evidence["architecture"] != arch + or evidence["apiVersion"] != contract["apiVersion"]): + raise ValueError("published build identity mismatch") + if hashlib.sha256(binary.read_bytes()).hexdigest() != evidence["binarySha256"]: + raise ValueError("binary differs from tested native build") + verify_binary(binary, arch) + # Run the real consumer with real HTTPS downloads in an isolated profile. + plugin = Path(temp) / "plugin" + (plugin / "scripts").mkdir(parents=True) + shutil.copyfile(MAIL / "scripts/backend-runtime.py", plugin / "scripts/backend-runtime.py") + (plugin / "backend-version").write_text(version() + "\n") + (plugin / "backend-api.json").write_text(json.dumps(contract)) + pins = {a: checksums[f"omamail-linux-{a}.tar.gz"] for a in ARCHES} + (plugin / "backend-release.json").write_text(json.dumps(dict( + schemaVersion=1, version=version(), archives=pins))) + env = {key: os.environ[key] for key in ("PATH", "LANG") if key in os.environ} + env.update(HOME=temp, XDG_DATA_HOME=str(Path(temp) / "data")) + installed = json.loads(run("python3", str(plugin / "scripts/backend-runtime.py"), "install", env=env)) + if installed["state"] != "ready": + raise ValueError("real published install failed: " + installed["error"]) + if Path(installed["executable"]).read_bytes() != binary.read_bytes(): + raise ValueError("consumer installed different bytes from the public contract probe") + print(json.dumps(dict(version=version(), architecture=arch, publishedContract="passed"))) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + cmd = commands.add_parser("prepare") + cmd.add_argument("binary", type=Path) + cmd.add_argument("arch", choices=ARCHES) + cmd.add_argument("output", type=Path) + cmd = commands.add_parser("assemble") + cmd.add_argument("inputs", type=Path) + cmd.add_argument("output", type=Path) + cmd = commands.add_parser("verify") + cmd.add_argument("directory", type=Path) + cmd.add_argument("arch", choices=ARCHES) + commands.add_parser("version") + args = parser.parse_args() + if args.command == "prepare": + prepare(args.binary.resolve(), args.arch, args.output) + elif args.command == "assemble": + assemble(args.inputs, args.output) + elif args.command == "verify": + verify(args.directory, args.arch) + else: + print(version())