test(secrets): prove a wrapped secret survives a process restart

The property this crate exists for is the one no in-process test could
show. A single process wraps and unwraps happily against a master key
that never left memory, which is exactly what keyring's mock hands back —
so the whole suite stayed green while every offline download lost its
content key on the next launch.

The test re-executes the test binary: wrap in one child, unwrap in
another. It asserts persistence and not which backend provided it, so the
KDF fallback passes it too and a runner without secret-service is a pass
rather than a flake, while the mock fails everywhere.

The existing job is Linux-only, which is where these crates are least
likely to break — both bugs it now guards shipped on macOS while Linux
was fine. Adds a narrow macOS job for the two platform-sensitive crates:
no ALSA/JACK/D-Bus, no UI graph, a couple of minutes on a 10x runner.
This commit is contained in:
Afonso Jorge Ramos committed 2026-08-11 17:08:53 +02:00
1 parent 934a83fdac
commit 99ba4aa48e
2 files changed
+161

No files matched your search

+45
View File
@@ -103,3 +103,48 @@ jobs:
if [ -n "$hits" ]; then if [ -n "$hits" ]; then
echo "::error::qbzd graph resolves Slint crates:"; echo "$hits"; exit 1 echo "::error::qbzd graph resolves Slint crates:"; echo "$hits"; exit 1
fi fi
# The job above is Linux-only, which is where the platform-sensitive crates
# are *least* likely to break: the two bugs this job exists for both shipped
# on macOS while Linux was fine.
#
# - qbz-secrets picks a keyring backend per platform, and picked an
# in-memory mock on macOS and Windows whose writes silently succeed.
# - qbz-offline-cache resolves bundle paths under an app directory that
# only moves on macOS-derived platforms (iOS reassigns the data
# container; the desktop cache root is stable).
#
# Deliberately narrow: two crates, no ALSA/JACK/D-Bus, no UI graph, so this
# stays a couple of minutes on a runner billed at 10x Linux. Add
# `windows-latest` to the matrix if the same class of bug turns up there —
# the tests are already platform-agnostic.
platform-sensitive:
name: cargo test (secrets + offline cache, ${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [macos-14]
runs-on: ${{ matrix.os }}
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: crates -> target
shared-key: test-crates-${{ matrix.os }}
cache-on-failure: true
save-if: ${{ github.ref == 'refs/heads/pre-release' }}
- name: cargo test
env:
CARGO_TERM_COLOR: always
CARGO_BUILD_JOBS: "3"
run: |
cargo test --manifest-path crates/Cargo.toml \
-p qbz-secrets -p qbz-offline-cache --no-fail-fast
@@ -0,0 +1,116 @@
//! The one property this crate exists for, and the one no in-process test can
//! demonstrate: a secret wrapped by one run of the app can be unwrapped by the
//! next.
//!
//! Everything else here passes whether or not that holds. A single process can
//! wrap and unwrap against a master key that never left memory and never
//! reached a store, which is exactly what keyring's `mock` backend hands back
//! when no platform feature is enabled — silently, while `Backend::new` reports
//! `BackendKind::Keyring`. That shipped on macOS and Windows and cost every
//! offline download its content key on the next launch.
//!
//! So this test crosses a process boundary. It re-executes the test binary,
//! wraps in the child, then re-executes again and unwraps in a second child.
//!
//! Deliberately **backend-agnostic**: it asserts persistence, not which store
//! provided it. The KDF fallback satisfies it too (its install id is on disk),
//! so a runner with no secret-service is a pass rather than a flake — while the
//! mock, which satisfies neither, fails everywhere.
use std::path::Path;
use std::process::Command;
/// Set on the children. Its value is the half of the exchange to perform.
const ROLE: &str = "QBZ_SECRETS_RESTART_ROLE";
const SERVICE: &str = "QBZ_SECRETS_RESTART_SERVICE";
const DIR: &str = "QBZ_SECRETS_RESTART_DIR";
const SECRET: &[u8; 16] = b"0123456789abcdef";
#[test]
fn a_wrapped_secret_survives_a_process_restart() {
if let Ok(role) = std::env::var(ROLE) {
run_child(&role);
return;
}
let dir = tempfile::tempdir().expect("tempdir");
// Unique per run so concurrent jobs — and a developer's real qbz entry —
// never collide. Cleaned up at the end.
let service = format!(
"qbz-secrets-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos()
);
let wrapped = spawn_self("wrap", &service, dir.path());
assert!(
wrapped.status.success(),
"the first run could not wrap a secret:\n{}",
String::from_utf8_lossy(&wrapped.stderr)
);
let unwrapped = spawn_self("unwrap", &service, dir.path());
let stderr = String::from_utf8_lossy(&unwrapped.stderr).into_owned();
forget(&service);
assert!(
unwrapped.status.success(),
"a secret wrapped by one process could not be unwrapped by the next — \
the master key did not outlive the process that made it:\n{stderr}"
);
}
/// Re-execute this test binary with `ROLE` set, so the child runs the body
/// above and takes the branch at the top.
fn spawn_self(role: &str, service: &str, dir: &Path) -> std::process::Output {
Command::new(std::env::current_exe().expect("current_exe"))
.args([
"--exact",
"a_wrapped_secret_survives_a_process_restart",
"--nocapture",
])
.env(ROLE, role)
.env(SERVICE, service)
.env(DIR, dir)
.output()
.expect("re-exec the test binary")
}
fn run_child(role: &str) {
let service = std::env::var(SERVICE).expect(SERVICE);
let dir = std::env::var(DIR).expect(DIR);
let blob = Path::new(&dir).join("wrapped.bin");
let vault = qbz_secrets::SecretBox::open(&service, Path::new(&dir))
.unwrap_or_else(|e| panic!("open the vault: {e}"));
eprintln!("child role={role} backend={:?}", vault.backend_kind());
match role {
"wrap" => {
let wrapped = vault.wrap(SECRET).unwrap_or_else(|e| panic!("wrap: {e}"));
std::fs::write(&blob, wrapped).expect("write the wrapped blob");
}
_ => {
let wrapped = std::fs::read(&blob).expect("read the wrapped blob");
let plain = vault.unwrap(&wrapped).unwrap_or_else(|e| panic!("unwrap: {e}"));
assert_eq!(plain, SECRET, "the secret came back changed");
}
}
}
/// Drop the entry this run created. Best-effort: the KDF fallback never made
/// one, and a store that refuses the delete is not this test's business.
#[cfg(not(any(target_os = "android", target_os = "ios")))]
fn forget(service: &str) {
if let Ok(entry) = keyring::Entry::new(service, "master-key-v1") {
let _ = entry.delete_credential();
}
}
#[cfg(any(target_os = "android", target_os = "ios"))]
fn forget(_service: &str) {}