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:
2 files changed
+161
No files matched your search
@@ -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) {}
|
||||||
Reference in new issue
Block a user