feat(buzz-agent): persist the OAuth token cache owner-only on Windows

On Windows the token cache was memory-only because std has no way to set
an owner-only DACL, and the Win32 calls need `unsafe`, which buzz-agent
forbids. Each process therefore ran its own sign-in, and Databricks
stayed gated off on Windows in the desktop app.

The new buzz-private-file crate holds that FFI behind a safe API, so
buzz-agent keeps `forbid(unsafe_code)` on every platform:

- `create_new` creates a file with the protected DACL `D:P(A;;FA;;;OW)`
  set atomically at creation, the Windows counterpart of `mode(0o600)`.
- `open_owned` opens without following a final link, refuses anything
  but a regular file owned by the process's default owner, and then
  re-applies that DACL on the open handle, like the Unix `O_NOFOLLOW` +
  `fchmod` repair.

auth.rs now persists and reads the cache on Windows through these, with
the same temp-file + rename lifecycle as Unix. Tests that were Unix-only
only because Windows had no disk cache now run on both, and Windows CI
runs the helper crate (including its elevated planted-owner and symlink
tests) plus the buzz-agent token-cache unit tests.

Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
This commit is contained in:
Pinky
2026-09-30 11:05:14 -06:00
parent 0ee6093790
commit ad01b708f8
13 changed files with 507 additions and 387 deletions

View File

@@ -154,20 +154,23 @@ jobs:
- name: Test (ACP launch-prefix platform contract)
# Execute the non-Unix refusal branch; a cross-compile cannot test it.
run: cargo test -p buzz-acp --target $env:TARGET --lib acp::launch::platform_tests
- name: Test (owner-only private files)
# The Win32 DACL code behind buzz-agent's token cache. The ignored
# tests plant a foreign-owned file and a symlink, which needs the
# runner's elevated shell.
run: cargo test -p buzz-private-file --target $env:TARGET -- --include-ignored
- name: Test (buzz-agent auth coordinator)
# The auth coordinator single-flights on an OS advisory lock, which is
# LockFileEx on Windows; this integration suite drives real second
# processes on the same lock file, so it only exercises the Windows
# lock runtime if it runs ON Windows. Every other job compiles it but
# never executes it. Tests exercised on Windows: lock serialization
# (two coordinators race for the same key), cooldown sidecar sharing
# across processes, attempt-sidecar adoption (UserInitiated waiter
# adopts a predecessor's denial), and the in-process single-flight for
# same-key coalescing. Tests that are UNIX-ONLY and NOT executed here:
# crash-release (flock drop on SIGKILL, guarded by #[cfg(unix)]) and
# cross-process cache success/race (on-disk token handoff, also
# #[cfg(unix)]).
# LockFileEx on Windows, and hands tokens across processes through the
# owner-only disk cache. This integration suite drives real second
# processes on the same lock and cache files, so it only exercises the
# Windows runtime if it runs ON Windows. Every other job compiles it but
# never executes it.
run: cargo test -p buzz-agent --target $env:TARGET --test databricks_auth_coordinator
- name: Test (buzz-agent token cache)
# Disk cache reuse, joiner recovery and concurrent savers through the
# Windows owner-only cache path.
run: cargo test -p buzz-agent --target $env:TARGET --lib -- auth::tests databricks::tests
# Smoke-test the new host-prereq contract: Git for Windows (which provides
# bash) is available on the runner, a shell command round-trips, and bash
# does NOT resolve from System32 (so WSL's launcher is never picked up).

9
Cargo.lock generated
View File

@@ -1018,6 +1018,7 @@ dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"buzz-private-file",
"dirs",
"fs2",
"getrandom 0.4.3",
@@ -1361,6 +1362,14 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "buzz-private-file"
version = "0.1.0"
dependencies = [
"tempfile",
"windows-sys 0.61.2",
]
[[package]]
name = "buzz-pubsub"
version = "0.1.0"

View File

@@ -12,6 +12,7 @@ members = [
"crates/buzz-audit",
"crates/buzz-acp",
"crates/buzz-agent",
"crates/buzz-private-file",
"crates/sprig",
"crates/buzz-test-client",
"crates/buzz-ws-client",

View File

@@ -72,6 +72,11 @@ fs2 = "0.4"
[target.'cfg(unix)'.dependencies]
nix = { version = "0.31", default-features = false, features = ["signal", "process"] }
# Owner-only token cache files on Windows; keeps the Win32 FFI (and its
# `unsafe`) out of this crate.
[target.'cfg(windows)'.dependencies]
buzz-private-file = { path = "../buzz-private-file" }
[dev-dependencies]
# Synthetic TLS OAuth/catalog servers exercise the strict HTTPS production path.
rcgen = { version = "0.14", default-features = false, features = ["crypto", "ring"] }

View File

@@ -18,8 +18,9 @@ without running an agent, ACP, a CLI subprocess or any runtime configuration.
- The caller chooses and exclusively owns its cache root BEFORE construction.
`databricks-strict` separates strict state from legacy `databricks` caches and
single-flight coordination even if roots coincide. It is not credential
migration. Existing host/client/scopes hash and Unix owner-only atomic token
persistence remain. Non-Unix token persistence remains disabled.
migration. Existing host/client/scopes hash and owner-only atomic token
persistence remain: mode `0600` on Unix, a protected owner-only DACL on
Windows.
- Keep secrets native. The supplied opener must not log authorization URLs or
its failure details. Drop the operation future to cancel actual requests and
the callback listener. Set an app-level overall deadline, generation-fence

View File

@@ -516,20 +516,8 @@ impl PkceOAuthTokenSource {
/// Persist a token to disk and the in-memory cell.
///
/// The cache holds both the access and refresh tokens, so the on-disk
/// file is written owner-only (`0o600` on Unix) via an atomic
/// inode-swapping rename — see [`write_private_cache`].
///
/// On non-Unix platforms the token is stored in-memory only: the
/// `write_private_cache` path creates files with default ACLs, which do
/// not enforce owner-only access. Disk persistence is intentionally
/// disabled until a Windows-specific owner-only DACL is implemented (see
/// the `create_private_temp_file` non-Unix branch). The cost is that each
/// process performs its own acquisition on non-Unix — cross-process
/// *success* handoff requires the shared on-disk cache, so processes
/// serialize through the lock but the loser repeats the flow rather than
/// reading the winner's token. Cross-process *failure* adoption still works
/// because it uses the attempt sidecar (no token bytes). Correct and
/// safe until owner-only DACL persistence exists.
/// file is written owner-only (`0o600` on Unix, an owner-only DACL on
/// Windows) via an atomic rename — see [`write_private_cache`].
fn save(&self, state: &mut Option<CachedToken>, token: CachedToken) -> Result<(), AgentError> {
self.persist(&token)?;
*state = Some(token);
@@ -538,23 +526,12 @@ impl PkceOAuthTokenSource {
/// Write `token` to the on-disk cache. Split out of [`save`](Self::save) so
/// the 401 neutralization path can rewrite the disk layer without clobbering
/// a distinct in-memory entry. No-op on non-Unix (see [`save`](Self::save)).
/// a distinct in-memory entry.
fn persist(&self, token: &CachedToken) -> Result<(), AgentError> {
#[cfg(unix)]
{
let body = serde_json::to_vec_pretty(token)
.map_err(|e| AgentError::Llm(format!("oauth cache serialize: {e}")))?;
write_private_cache(&self.cache_path, &body).map_err(|e| {
AgentError::Llm(format!("oauth cache write {:?}: {e}", self.cache_path))
})?;
}
#[cfg(not(unix))]
{
// Disk persistence disabled on non-Unix: owner-only file
// permissions require a DACL that is not yet implemented.
let _ = token;
}
Ok(())
let body = serde_json::to_vec_pretty(token)
.map_err(|e| AgentError::Llm(format!("oauth cache serialize: {e}")))?;
write_private_cache(&self.cache_path, &body)
.map_err(|e| AgentError::Llm(format!("oauth cache write {:?}: {e}", self.cache_path)))
}
/// Neutralize the matching rejected credential in B's own in-memory `state`
@@ -1840,23 +1817,19 @@ fn read_private_cache(path: &Path) -> io::Result<Vec<u8>> {
Ok(body)
}
/// Non-Unix: token persistence and reading are both disabled until a
/// Windows-specific owner-only DACL is implemented. Any legacy token file
/// left by an older build (written with default ACLs) is deleted
/// opportunistically so the exposed artifact cannot be served by new builds.
/// Returns an error so [`read_cache`] yields `None`, giving a consistent
/// memory-only cache on non-Unix.
#[cfg(not(unix))]
/// Open the cache without following a final link, refuse it unless it is a
/// regular file owned by the current user, re-apply the owner-only DACL on
/// the open handle, and return its bytes.
///
/// The Windows counterpart of the Unix `O_NOFOLLOW` + `fchmod` repair; see
/// [`buzz_private_file::open_owned`].
#[cfg(windows)]
fn read_private_cache(path: &Path) -> io::Result<Vec<u8>> {
// Best-effort removal of any legacy file. Errors are ignored — either the
// file does not exist (normal case) or it cannot be removed (no worse
// than before — the DACL story is still broken, but that is the pre-fix
// state we are trying to retire).
let _ = fs::remove_file(path);
Err(io::Error::new(
io::ErrorKind::Unsupported,
"token disk cache disabled on non-Unix (no owner-only DACL)",
))
use std::io::Read;
let mut body = Vec::new();
buzz_private_file::open_owned(path)?.read_to_end(&mut body)?;
Ok(body)
}
/// Removes a temp file on drop unless it was already renamed away. Keeps a
@@ -1894,10 +1867,9 @@ fn unique_suffix() -> String {
/// world/other readable, write and fsync it, then rename over the
/// destination. The rename swaps the inode/entry wholesale, so a pre-existing
/// cache file with loose permissions is *replaced* by the new private one;
/// its old mode never survives. `fs::rename` maps to
/// `MOVEFILE_REPLACE_EXISTING` on Windows, so the atomic replace holds on
/// both platforms; the Windows owner-only DACL is pending the unsafe-FFI
/// decision noted at the seam.
/// its old mode or DACL never survives. `fs::rename` replaces an existing
/// destination on Windows too, so the atomic replace holds on both
/// platforms.
fn write_private_cache(path: &Path, body: &[u8]) -> io::Result<()> {
let parent = path.parent().ok_or_else(|| {
io::Error::new(
@@ -1938,22 +1910,11 @@ fn create_private_temp_file(tmp: &Path) -> io::Result<fs::File> {
.open(tmp)
}
/// Non-Unix fallback: create the temp file if it does not already exist.
///
/// On Windows the owner-only equivalent is an explicit DACL set at creation
/// (`CreateFileW` with SDDL `D:P(A;;FA;;;OW)`, matching goose's
/// `private_file.rs`), but that FFI needs `unsafe`, which this crate forbids.
/// Reconciling the two — an isolated helper crate, a vetted safe dependency,
/// or descoping Windows — is an open decision escalated to the maintainer, so
/// this interim relies on the default per-user ACLs and drops the owner-only
/// implementation in behind this seam once the decision lands. `create_new`
/// fails if the file already exists.
#[cfg(not(unix))]
/// Windows: the same, with a protected owner-only DACL set atomically at
/// creation instead of a mode.
#[cfg(windows)]
fn create_private_temp_file(tmp: &Path) -> io::Result<fs::File> {
fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(tmp)
buzz_private_file::create_new(tmp)
}
/// Parse a token-endpoint JSON response. Fails loudly when `access_token`
@@ -2327,7 +2288,6 @@ mod tests {
assert!(token_from_response(&v, None).is_err());
}
#[cfg(unix)] // Disk adoption relies on `write_private_cache`; non-Unix disables disk persistence.
#[tokio::test]
async fn test_bearer_reuses_disk_token_after_expiry() {
let dir = tempfile::tempdir().unwrap();
@@ -2381,10 +2341,6 @@ mod tests {
/// exercises the joiner recovery branch rather than the initial fast-path
/// `cached_hit`. Removing the joiner disk-recovery branch must make the
/// test return Err(RefreshRejected) rather than Ok("sibling-replacement").
///
/// Disk-dependent: the replacement lives on disk, so `write_private_cache`
/// must be available (i.e. Unix only).
#[cfg(unix)]
#[tokio::test]
async fn test_joiner_shared_failure_recovers_disk_replacement() {
use std::future::Future as _;
@@ -2487,7 +2443,6 @@ mod tests {
/// "disk unchanged" assertion fails — proving the unfenced write is exactly
/// the race that would overwrite any concurrent C write that landed between
/// A's failure and B's reconciliation.
#[cfg(unix)]
#[tokio::test]
async fn test_joiner_failure_does_not_write_disk() {
use std::future::Future as _;
@@ -2766,11 +2721,39 @@ mod tests {
// ---- private atomic cache write --------------------------------------
#[cfg(unix)]
/// Mode `0600` on Unix; on Windows a protected DACL whose only entry
/// grants the owner full access.
fn assert_owner_only(path: &Path, why: &str) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "{why}: got {mode:o}");
}
#[cfg(windows)]
{
// SDDL, unlike `icacls` output, is not localized.
let out = std::process::Command::new("powershell")
.args([
"-NoProfile",
"-Command",
"(Get-Acl -LiteralPath $env:ACL_PATH).Sddl",
])
.env("ACL_PATH", path)
.output()
.unwrap();
let sddl = String::from_utf8_lossy(&out.stdout);
let dacl = sddl.trim().split_once("D:").map_or("", |(_, dacl)| dacl);
let (flags, aces) = dacl.split_once('(').unwrap_or_default();
assert!(
flags.contains('P') && aces == "A;;FA;;;OW)",
"{why}: got {sddl}"
);
}
}
#[tokio::test]
async fn test_save_writes_owner_only_cache_file() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let cfg = PkceOAuthConfig {
discovery_url: "https://example.com/.well-known".into(),
@@ -2795,16 +2778,7 @@ mod tests {
.unwrap();
}
let mode = fs::metadata(&source.cache_path)
.unwrap()
.permissions()
.mode();
assert_eq!(
mode & 0o777,
0o600,
"cache file must be owner-only, got {:o}",
mode & 0o777
);
assert_owner_only(&source.cache_path, "cache file must be owner-only");
// No temp file left behind.
let leftovers: Vec<_> = fs::read_dir(dir.path().join("test"))
.unwrap()
@@ -2860,11 +2834,8 @@ mod tests {
assert_ne!(meta.ino(), old_inode, "cache inode was not replaced");
}
#[cfg(unix)]
#[test]
fn test_write_private_cache_concurrent_savers_all_succeed() {
use std::os::unix::fs::PermissionsExt;
// Unique tmp suffixes must let many concurrent writers to the SAME
// destination each land a complete, private file — no tmp collision,
// no failed rename. The fixed `*.json.tmp` name this replaces would
@@ -2889,12 +2860,11 @@ mod tests {
.expect("concurrent write_private_cache failed");
}
// Destination exists, is valid (one writer's complete body), and 0600.
// Destination exists and is valid (one writer's complete body).
let contents = fs::read_to_string(path.as_ref()).unwrap();
let v: serde_json::Value = serde_json::from_str(&contents).unwrap();
assert!(v.get("n").is_some(), "cache body was corrupted: {contents}");
let mode = fs::metadata(path.as_ref()).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
assert_owner_only(&path, "concurrent saves must stay owner-only");
// No temp files leaked.
let leftovers: Vec<_> = fs::read_dir(dir.path())
@@ -2907,14 +2877,12 @@ mod tests {
// ---- owner-only is a load-time invariant, not just a save-time one ----
#[cfg(unix)]
#[tokio::test]
async fn test_bearer_cache_hit_tightens_preexisting_loose_mode_file() {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
// A world-readable cache left by an older buzz-agent must be tightened
// the moment we load it — on the plain cache-hit path, with no refresh
// or save. Otherwise the pre-bug population stays exposed indefinitely.
// On Windows a plain `fs::write` already inherits the directory's ACL.
let dir = tempfile::tempdir().unwrap();
let cfg = PkceOAuthConfig {
discovery_url: "https://example.com/.well-known".into(),
@@ -2939,26 +2907,32 @@ mod tests {
expires_at: Some(future_exp),
};
fs::write(&cache_path, serde_json::to_vec_pretty(&token).unwrap()).unwrap();
fs::set_permissions(&cache_path, fs::Permissions::from_mode(0o644)).unwrap();
let old_inode = fs::metadata(&cache_path).unwrap().ino();
#[cfg(unix)]
let old_inode = {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
fs::set_permissions(&cache_path, fs::Permissions::from_mode(0o644)).unwrap();
fs::metadata(&cache_path).unwrap().ino()
};
// Constructing the source loads the cache — repair happens here.
let source = PkceOAuthTokenSource::new(cfg).unwrap();
let bearer = source.bearer().await.unwrap();
assert_eq!(bearer, "loose-but-valid");
let meta = fs::metadata(&cache_path).unwrap();
assert_eq!(
meta.permissions().mode() & 0o777,
0o600,
"existing loose-mode cache was not tightened on load"
assert_owner_only(
&cache_path,
"existing loose cache was not tightened on load",
);
// Repaired in place on the open fd — same inode, no rewrite/rename.
assert_eq!(
meta.ino(),
old_inode,
"load-time repair should fchmod in place, not replace the inode"
);
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
assert_eq!(
fs::metadata(&cache_path).unwrap().ino(),
old_inode,
"load-time repair should fchmod in place, not replace the inode"
);
}
}
#[cfg(unix)]

View File

@@ -36,8 +36,8 @@ impl DatabricksConnection {
/// `workspace` must be an HTTPS origin (optional trailing slash), without
/// userinfo, query or fragment. `cache_root` must be absolute and owned by
/// this app; a separate `databricks-strict` namespace prevents accidental
/// coalescing with legacy auth. Unix token files retain owner-only atomic
/// persistence; on non-Unix the existing engine keeps credentials in memory.
/// coalescing with legacy auth. Token files keep owner-only atomic
/// persistence.
/// The opener must not log the authorization URL or its own error details.
pub fn new(
workspace: &str,

View File

@@ -28,16 +28,13 @@ impl Request {
}
pub(super) enum Reply {
Json(u16, serde_json::Value),
#[cfg(unix)]
Raw(String),
#[cfg(unix)]
Stall,
}
pub(super) struct Server {
pub base: String,
pub cert: Option<reqwest::Certificate>,
pub requests: Arc<Mutex<Vec<Request>>>,
#[cfg_attr(not(unix), allow(dead_code))]
pub active: Arc<AtomicUsize>,
task: tokio::task::JoinHandle<()>,
}
@@ -124,7 +121,6 @@ impl Server {
.filter(|r| r.path.starts_with(path))
.count()
}
#[cfg(unix)]
pub async fn wait_for(&self, path: &str) {
tokio::time::timeout(Duration::from_secs(3), async {
while self.count(path) == 0 {
@@ -203,9 +199,7 @@ async fn serve<S: AsyncRead + AsyncWrite + Unpin>(
let body = body.to_string();
format!("HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len())
}
#[cfg(unix)]
Reply::Raw(raw) => raw,
#[cfg(unix)]
Reply::Stall => {
// Send headers, then stall the body until the client cancels.
let _ = stream
@@ -268,7 +262,6 @@ pub(super) fn discovery(base: &str) -> Reply {
serde_json::json!({"authorization_endpoint": format!("{base}/authorize"), "token_endpoint": format!("{base}/token")}),
)
}
#[cfg(unix)]
pub(super) fn seed(root: &Path, base: &str, namespace: &str, token: &str, expired: bool) {
use sha2::{Digest, Sha256};
let key = format!(
@@ -278,7 +271,6 @@ pub(super) fn seed(root: &Path, base: &str, namespace: &str, token: &str, expire
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(format!("{}.json", hex::encode(Sha256::digest(key)))), serde_json::json!({"access_token": token, "refresh_token": "synthetic-refresh", "expires_at": if expired { 1 } else { 4_000_000_000u64 }}).to_string()).unwrap();
}
#[cfg(unix)]
pub(super) fn chunked(status: u16, body: &str) -> Reply {
Reply::Raw(format!("HTTP/1.1 {status} Test\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n{:x}\r\n{body}\r\n0\r\n\r\n", body.len()))
}

View File

@@ -1,8 +1,6 @@
use super::*;
#[cfg(unix)]
use crate::auth::TokenSource;
use serde_json::json;
#[cfg(unix)]
use std::sync::atomic::Ordering;
#[path = "databricks_test_support.rs"]
@@ -12,7 +10,6 @@ use support::*;
fn strict(server: &Server, root: &Path, opener: Arc<Opener>) -> DatabricksConnection {
DatabricksConnection::build(&server.base, root, opener, server.builder()).unwrap()
}
#[cfg(unix)]
fn legacy(server: &Server, root: &Path, opener: Arc<Opener>) -> Arc<PkceOAuthTokenSource> {
PkceOAuthTokenSource::new_with(
crate::llm::databricks_pkce_config(&server.base, Some(root.into())),
@@ -74,7 +71,6 @@ async fn strict_headless_empty_never_networks_or_browses() {
assert!(opener.urls.lock().unwrap().is_empty());
}
#[cfg(unix)]
#[tokio::test]
async fn strict_rejects_discovered_endpoints_before_grants_or_browser() {
let sink = Server::start(true, |_, _| {
@@ -204,7 +200,6 @@ async fn strict_connect_code_exchange_catalog_and_headless_401_refresh() {
);
}
#[cfg(unix)]
#[tokio::test]
async fn strict_never_follows_discovery_token_or_catalog_redirects() {
let sink = Server::start(true, |_, _| {
@@ -274,7 +269,6 @@ async fn strict_never_follows_discovery_token_or_catalog_redirects() {
assert_eq!(sink.count("/control"), 1);
}
#[cfg(unix)]
#[tokio::test]
async fn strict_catalog_preserves_empty_filter_partial_and_paging_semantics() {
for scenario in ["empty", "filtered-empty", "partial", "pages"] {
@@ -323,7 +317,6 @@ async fn strict_catalog_preserves_empty_filter_partial_and_paging_semantics() {
}
}
#[cfg(unix)]
#[tokio::test]
async fn strict_cancellation_drops_real_http_work_and_allows_retry() {
for stage in ["discovery", "refresh", "exchange", "catalog"] {
@@ -432,7 +425,6 @@ async fn strict_browser_wait_cancellation_releases_callback_listener() {
.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn legacy_different_origin_endpoints_and_redirects_remain_supported() {
let sink = Server::start(false, |_, r| {
@@ -462,7 +454,6 @@ async fn legacy_different_origin_endpoints_and_redirects_remain_supported() {
assert_eq!(sink.count("/token"), 2);
}
#[cfg(unix)]
#[tokio::test]
async fn strict_host_root_and_legacy_namespace_isolation() {
let server = Server::start(true, |_, _| panic!("should not reach network")).await;
@@ -500,7 +491,6 @@ async fn strict_host_root_and_legacy_namespace_isolation() {
assert_eq!(c.source.bearer_no_browser().await.unwrap(), "strict-a");
}
#[cfg(unix)]
#[tokio::test]
async fn shared_oauth_limits_bound_chunked_and_declared_bodies_in_both_paths() {
use crate::auth_http::{MAX_OAUTH_ERROR_BYTES, MAX_OAUTH_RESPONSE_BYTES};
@@ -573,7 +563,6 @@ async fn shared_oauth_limits_bound_chunked_and_declared_bodies_in_both_paths() {
}
}
#[cfg(unix)]
#[tokio::test]
async fn shared_exact_limit_responses_and_grant_classifications_are_preserved() {
use crate::auth_http::{MAX_OAUTH_ERROR_BYTES, MAX_OAUTH_RESPONSE_BYTES};
@@ -646,7 +635,6 @@ async fn shared_exact_limit_responses_and_grant_classifications_are_preserved()
}
}
#[cfg(unix)]
#[tokio::test]
async fn shared_diagnostics_never_log_oauth_bodies_urls_opener_or_callback_details() {
use std::{io::Write, sync::Mutex};
@@ -790,7 +778,6 @@ async fn strict_malformed_token_expiry_is_a_safe_infrastructure_failure() {
);
}
#[cfg(unix)]
#[tokio::test]
async fn strict_catalog_errors_redact_provider_text_and_urls() {
let server = Server::start(true, |_, _| {

View File

@@ -146,35 +146,19 @@ enum RefreshMode {
Reject,
/// `500` — a provider-side fault, transient rather than a credential
/// decision.
///
/// Used only by Unix-only tests (refresh-error classification).
/// Gated to suppress dead-code warnings on Windows.
#[cfg(unix)]
ServerError,
/// A 4xx with the given OAuth `error` code in the body. Lets a test assert
/// the coordinator treats `invalid_grant` (any 4xx) as a dead grant, but
/// every other error code — and any non-`invalid_grant` status like `429`
/// — as infrastructural rather than a credential rejection.
///
/// Used only by Unix-only tests (refresh-error classification).
/// Gated to suppress dead-code warnings on Windows.
#[cfg(unix)]
ClientError(axum::http::StatusCode, &'static str),
/// Sleep `d` before answering, so the caller's per-request HTTP timeout
/// elapses first (a transport timeout, not a verdict from the provider).
///
/// Used only by Unix-only tests (refresh-timeout classification).
/// Gated to suppress dead-code warnings on Windows.
#[cfg(unix)]
Hang(Duration),
/// `200` returning the same fixed access token on every grant, regardless
/// of how many are served. Models a provider that re-issues an identical
/// access token, so a bounded rerun can hand back the exact bytes the
/// caller already reported 401-rejected.
///
/// Used only by Unix-only tests (rejected-token neutralization, sticky
/// reissuance). Gated to suppress dead-code warnings on Windows.
#[cfg(unix)]
SucceedSticky(&'static str),
}
@@ -201,10 +185,6 @@ enum ExchangeMode {
/// exchange. Models a provider that re-issues an identical access token, so
/// a browser sign-in (reached after a dead refresh) can hand back the exact
/// bytes the caller reported 401-rejected.
///
/// Used only by Unix-only tests (sticky browser exchange after dead refresh).
/// Gated to suppress dead-code warnings on Windows.
#[cfg(unix)]
SucceedSticky(&'static str),
}
@@ -274,7 +254,6 @@ async fn spawn_stub_with_modes(refresh: RefreshMode, exchange: ExchangeMode) ->
// A hang delays the answer so the caller's per-request
// HTTP timeout can elapse first (transport timeout, not
// a credential decision).
#[cfg(unix)]
if let RefreshMode::Hang(d) = refresh {
tokio::time::sleep(d).await;
}
@@ -283,12 +262,10 @@ async fn spawn_stub_with_modes(refresh: RefreshMode, exchange: ExchangeMode) ->
axum::http::StatusCode::UNAUTHORIZED,
Json(json!({ "error": "invalid_grant" })),
),
#[cfg(unix)]
RefreshMode::ServerError => (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "temporarily_unavailable" })),
),
#[cfg(unix)]
RefreshMode::ClientError(status, error) => {
(status, Json(json!({ "error": error })))
}
@@ -300,7 +277,6 @@ async fn spawn_stub_with_modes(refresh: RefreshMode, exchange: ExchangeMode) ->
"expires_in": 3600,
})),
),
#[cfg(unix)]
RefreshMode::Hang(_) => (
axum::http::StatusCode::OK,
Json(json!({
@@ -309,7 +285,6 @@ async fn spawn_stub_with_modes(refresh: RefreshMode, exchange: ExchangeMode) ->
"expires_in": 3600,
})),
),
#[cfg(unix)]
RefreshMode::SucceedSticky(tok) => (
axum::http::StatusCode::OK,
Json(json!({
@@ -343,7 +318,6 @@ async fn spawn_stub_with_modes(refresh: RefreshMode, exchange: ExchangeMode) ->
axum::http::StatusCode::OK,
Json(json!({ "token_type": "bearer" })),
),
#[cfg(unix)]
ExchangeMode::SucceedSticky(tok) => (
axum::http::StatusCode::OK,
Json(json!({
@@ -383,7 +357,6 @@ async fn spawn_stub_with_modes(refresh: RefreshMode, exchange: ExchangeMode) ->
/// deterministic ordering: the parent waits for `request_received` (proves A
/// holds the lock and is mid-refresh), then spawns B, waits for B's snapshot
/// marker, and finally calls `release()` before joining both workers.
#[cfg(unix)]
struct RefreshGate {
/// Notified by the stub once it has received the first refresh request.
request_received: Arc<tokio::sync::Notify>,
@@ -391,7 +364,6 @@ struct RefreshGate {
proceed: Arc<tokio::sync::Notify>,
}
#[cfg(unix)]
impl RefreshGate {
/// Asynchronously wait until the stub has received A's refresh request.
async fn wait_for_request(&self) {
@@ -408,7 +380,6 @@ impl RefreshGate {
///
/// - `Sticky(tok)` — every refresh returns `200 OK` with `access_token: tok`.
/// - `Reject` — every refresh returns `401 Unauthorized` with `invalid_grant`.
#[cfg(unix)]
enum HeldRefreshResponse {
Sticky(&'static str),
Reject,
@@ -422,7 +393,6 @@ enum HeldRefreshResponse {
///
/// Returns the stub (for `refresh_grants` / `code_grants` assertions) and the
/// control gate. Used by the cross-process held-refresh tests.
#[cfg(unix)]
async fn spawn_stub_with_held_refresh(response: HeldRefreshResponse) -> (Stub, RefreshGate) {
let code_grants = Arc::new(AtomicU64::new(0));
let refresh_grants = Arc::new(AtomicU64::new(0));
@@ -569,7 +539,6 @@ fn attempt_sidecar_path(cfg: &PkceOAuthConfig, cache_dir: &std::path::Path) -> s
/// coordinator's `append_ext(cache_path, "lock")`. Used to point the
/// out-of-process lock-holder helper at the exact file the coordinator
/// contends on.
#[cfg(unix)]
fn lock_file_path(cfg: &PkceOAuthConfig, cache_dir: &std::path::Path) -> std::path::PathBuf {
let mut p = cache_file_path(cfg, cache_dir).into_os_string();
p.push(".lock");
@@ -780,7 +749,6 @@ async fn test_browser_open_failure_is_typed_and_retryable_by_user() {
assert_eq!(approve_opener.call_count(), 1);
}
#[cfg(unix)]
#[tokio::test]
async fn test_headless_dead_refresh_returns_refresh_rejected_without_browser() {
let stub = spawn_stub(true).await; // refresh grants 401
@@ -816,7 +784,6 @@ async fn test_headless_dead_refresh_returns_refresh_rejected_without_browser() {
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_interactive_dead_refresh_converts_to_browser() {
let stub = spawn_stub(true).await; // refresh grants 401
@@ -847,7 +814,6 @@ async fn test_interactive_dead_refresh_converts_to_browser() {
assert_eq!(stub.code_grants.load(Ordering::SeqCst), 1);
}
#[cfg(unix)]
#[tokio::test]
async fn test_headless_expired_token_live_refresh_recovers_silently() {
let stub = spawn_stub(false).await; // refresh succeeds
@@ -875,7 +841,6 @@ async fn test_headless_expired_token_live_refresh_recovers_silently() {
assert_eq!(stub.refresh_grants.load(Ordering::SeqCst), 1);
}
#[cfg(unix)]
#[tokio::test]
async fn test_interactive_login_reuses_valid_cache_without_browser() {
let stub = spawn_stub(false).await;
@@ -918,7 +883,6 @@ async fn test_interactive_login_reuses_valid_cache_without_browser() {
/// Seed a not-yet-expired access token with a (dead) refresh token and return
/// the access token so the caller can pass it as `rejected`.
#[cfg(unix)]
fn seed_fresh_rejectable(cfg: &PkceOAuthConfig, cache_dir: &std::path::Path) -> String {
let access = "fresh-but-rejected";
seed_cache(
@@ -933,7 +897,6 @@ fn seed_fresh_rejectable(cfg: &PkceOAuthConfig, cache_dir: &std::path::Path) ->
access.to_string()
}
#[cfg(unix)]
#[tokio::test]
async fn test_auto_rejected_fresh_bearer_with_dead_refresh_launches_browser() {
let stub = spawn_stub(true).await; // refresh grants 401
@@ -957,7 +920,6 @@ async fn test_auto_rejected_fresh_bearer_with_dead_refresh_launches_browser() {
assert_eq!(stub.code_grants.load(Ordering::SeqCst), 1);
}
#[cfg(unix)]
#[tokio::test]
async fn test_headless_rejected_fresh_bearer_with_dead_refresh_returns_refresh_rejected() {
let stub = spawn_stub(true).await; // refresh grants 401
@@ -989,7 +951,6 @@ async fn test_headless_rejected_fresh_bearer_with_dead_refresh_returns_refresh_r
// `RefreshRejected`, which would misreport a transient fault as a rotated
// token and (for interactive intents) prompt a needless sign-in.
#[cfg(unix)]
#[tokio::test]
async fn test_refresh_timeout_is_network_unavailable_not_rejected() {
// The token endpoint hangs far longer than the injected per-request HTTP
@@ -1043,7 +1004,6 @@ async fn test_refresh_timeout_is_network_unavailable_not_rejected() {
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_refresh_server_error_is_network_unavailable_not_rejected() {
let stub = spawn_stub_with(RefreshMode::ServerError).await; // refresh 500s
@@ -1091,7 +1051,6 @@ async fn test_refresh_server_error_is_network_unavailable_not_rejected() {
// and never pop a browser. The classifier keys on the OAuth error body, not
// the bare status class.
#[cfg(unix)]
#[tokio::test]
async fn test_refresh_400_invalid_grant_is_dead_grant_not_network() {
// A 400 (not just 401) carrying `invalid_grant` is still a dead refresh
@@ -1127,7 +1086,6 @@ async fn test_refresh_400_invalid_grant_is_dead_grant_not_network() {
assert_eq!(stub.refresh_grants.load(Ordering::SeqCst), 1);
}
#[cfg(unix)]
#[tokio::test]
async fn test_refresh_non_invalid_grant_4xx_is_network_unavailable_not_rejected() {
// Every 4xx whose OAuth body is NOT `invalid_grant` is a request/config or
@@ -1300,7 +1258,6 @@ async fn test_userinitiated_joiner_does_not_inherit_auto_cooldown_result() {
// dead. The joiner must instead detect the collision and run its own bounded
// acquisition, obtaining a token that differs from its `rejected`.
#[cfg(unix)]
#[tokio::test]
async fn test_joiner_never_receives_its_own_rejected_token() {
// Two concurrent `Headless` 401-recovery callers on one key, each rejecting
@@ -1384,7 +1341,6 @@ async fn test_joiner_never_receives_its_own_rejected_token() {
// this rerun terminate with a typed auth error before caching the rejected
// token rather than returning it.
#[cfg(unix)]
#[tokio::test]
async fn test_joiner_rerun_reissuing_rejected_token_fails_typed_not_loop() {
// A sticky provider returns ONE fixed access token on every refresh. Leader
@@ -1456,7 +1412,6 @@ async fn test_joiner_rerun_reissuing_rejected_token_fails_typed_not_loop() {
// Concrete scenario: A rejected X, refresh re-issues X → A gets RefreshRejected.
// B rejected Y (different), refresh would yield X for B → B succeeds.
#[cfg(unix)]
#[tokio::test]
async fn test_joiner_with_different_rejected_does_not_inherit_leaders_rejection_failure() {
// Sticky provider always returns "X" on every refresh grant.
@@ -1525,16 +1480,10 @@ async fn test_joiner_with_different_rejected_does_not_inherit_leaders_rejection_
// token X rather than the just-acquired Y (memory won over disk).
// - failure: B's matching rejected X remained live; its next `bearer()` still
// served it.
// - no-persistence (Windows): B's state stayed empty; its next headless read
// returned `NoCredential` instead of Y and a second browser opened.
//
// All three tests exercise the full `finish()` → `acquire_locked()` →
// Both tests exercise the full `finish()` → `acquire_locked()` →
// `acquire_leader()` → `LeaderGuard::complete()` → joiner wiring.
// Unix-specific: the seed provides a live refresh token. The non-Unix constructor
// does not read the disk cache, so without a seed in memory A's headless path
// returns NoCredential rather than RefreshRejected.
#[cfg(unix)]
#[tokio::test]
async fn test_inprocess_joiner_reconciles_stale_state_after_shared_success() {
// Scenario: A and B both loaded a locally-fresh-but-401'd token X. A leads,
@@ -1618,8 +1567,6 @@ async fn test_inprocess_joiner_reconciles_stale_state_after_shared_success() {
);
}
// Unix-specific: refresh token is required for a headless rejection path.
#[cfg(unix)]
#[tokio::test]
async fn test_inprocess_joiner_neutralizes_rejected_on_matching_shared_failure() {
// Scenario: A and B both carry unexpired X as their rejected token. A leads,
@@ -1686,90 +1633,6 @@ async fn test_inprocess_joiner_neutralizes_rejected_on_matching_shared_failure()
);
}
// Non-Unix-specific: disk persistence is disabled on Windows, so the only way
// for B to retain Y after joining is in-memory state reconciliation. On Unix
// the disk can provide Y as a fallback, masking a reconciliation failure.
#[cfg(not(unix))]
#[tokio::test]
async fn test_inprocess_joiner_populates_empty_state_no_second_acquisition() {
// Scenario: A and B both start with empty state (no disk token on non-Unix).
// A leads, opens a browser, exchanges the code for Y. B joins A's slot and
// wakes to Ok(Y). Without reconciliation, B.state stays None. B's next
// headless acquire returns NoCredential instead of Y, and a second browser
// would open if UserInitiated.
//
// Mutation check (no state reconciliation): B.state stays None. The
// subsequent headless acquire on B returns Err(NoCredential) instead of
// Ok("browser-token-1") — the assertion FAILS.
let stub = spawn_stub(false).await;
let cache = TempDir::new().unwrap();
let approve = ScriptedOpener::new(Script::Approve);
let a = PkceOAuthTokenSource::new_with(
config(&stub, "/disco/a", cache.path()),
Arc::new(approve.clone()),
)
.unwrap();
let b = PkceOAuthTokenSource::new_with(
config(&stub, "/disco/a", cache.path()),
Arc::new(approve.clone()),
)
.unwrap();
// Both start with empty state — UserInitiated falls through to a browser.
let (ra, rb) = tokio::join!(
a.acquire_with_intent(AuthIntent::UserInitiated, None),
b.acquire_with_intent(AuthIntent::UserInitiated, None),
);
assert_eq!(
ra,
Ok("browser-token-1".to_string()),
"leader (A) gets the browser token"
);
assert_eq!(
rb,
Ok("browser-token-1".to_string()),
"joiner (B) shares the leader's browser token"
);
assert_eq!(
approve.call_count(),
1,
"exactly one browser opened — B joined rather than launching its own"
);
assert_eq!(
stub.code_grants.load(Ordering::SeqCst),
1,
"exactly one authorization-code exchange"
);
// B's subsequent headless acquire must return Y from in-memory state without
// a second browser. Without reconciliation, B.state is None and headless
// returns NoCredential (no disk fallback on non-Unix).
let rb_next = b
.acquire_with_intent(AuthIntent::Headless, None)
.await
.expect(
"B subsequent headless read must return Y from in-memory state, not NoCredential — \
mutation check: fails if joiner state was not reconciled (bearer-only publication)",
);
assert_eq!(
rb_next, "browser-token-1",
"B retains Y in memory for subsequent headless reads"
);
// No second browser: B's subsequent read hit the in-memory cache.
assert_eq!(
approve.call_count(),
1,
"no second browser opened — B's subsequent headless read hit the in-memory cache"
);
assert_eq!(
stub.code_grants.load(Ordering::SeqCst),
1,
"no second code exchange"
);
}
// ---- a browser success that re-issues the rejected bytes must fail typed ---
//
// The 401-recovery invariant lives at `finish`'s persistence boundary, so it
@@ -1781,7 +1644,6 @@ async fn test_inprocess_joiner_populates_empty_state_no_second_acquisition() {
// bearer. A single interactive leader exercises the path; the colliding-joiner
// rerun routes through the same boundary.
#[cfg(unix)]
#[tokio::test]
async fn test_interactive_browser_reissuing_rejected_token_fails_typed_not_loop() {
// Refresh 401s (dead), so an interactive intent falls through to the
@@ -1843,7 +1705,6 @@ async fn test_interactive_browser_reissuing_rejected_token_fails_typed_not_loop(
// prove the cache is untouched after the typed failure, on both the refresh and
// the browser re-issue paths.
#[cfg(unix)]
#[tokio::test]
async fn test_sticky_refresh_rejection_does_not_poison_cache_for_later_callers() {
// A sticky provider re-issues `sticky-token` on every refresh. A caller that
@@ -1901,7 +1762,6 @@ async fn test_sticky_refresh_rejection_does_not_poison_cache_for_later_callers()
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_sticky_browser_rejection_does_not_poison_cache_for_later_callers() {
// Refresh is dead, so an interactive caller browses; the exchange stickily
@@ -1976,7 +1836,6 @@ async fn test_sticky_browser_rejection_does_not_poison_cache_for_later_callers()
// it rejected, so no future caller and no fresh process can serve it, while the
// refresh token — not rejected, and the engine of recovery — stays intact.
#[cfg(unix)]
#[tokio::test]
async fn test_rejected_fresh_token_is_neutralized_for_a_fresh_process() {
// The cached access token `A` is locally UNEXPIRED, and the provider
@@ -2046,7 +1905,6 @@ async fn test_rejected_fresh_token_is_neutralized_for_a_fresh_process() {
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_rejected_fresh_token_is_neutralized_for_the_same_source() {
// The in-memory layer of the same neutralization: after the SAME source
@@ -2092,7 +1950,6 @@ async fn test_rejected_fresh_token_is_neutralized_for_the_same_source() {
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_rejected_fresh_token_neutralized_when_recovery_browses() {
// The browser variant: `A` is unexpired but its refresh token is dead, so
@@ -2282,7 +2139,6 @@ async fn test_rejected_token_disk_neutralization_neutralizes_in_place_when_paren
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_rejected_token_in_memory_neutralized_when_disk_neutralization_skipped() {
// When `expire_rejected()` cannot read a matching disk entry (e.g. the cache
@@ -2358,7 +2214,6 @@ async fn test_rejected_token_in_memory_neutralized_when_disk_neutralization_skip
// must NOT be served as the replacement: doing so would skip the refresh the
// 401 demanded and hand back a token the provider will also reject.
#[cfg(unix)]
#[tokio::test]
async fn test_rejected_recovery_skips_expired_sibling_and_refreshes() {
let stub = spawn_stub(false).await; // refresh succeeds
@@ -2540,7 +2395,6 @@ async fn test_exchange_timeout_is_network_unavailable_not_cooldown() {
// crash mid-flow, and the kernel's release of the advisory lock is what lets
// the coordinator's successor proceed with no PID files and no lock breaking.
#[cfg(unix)]
#[tokio::test]
async fn test_crossprocess_lock_holder_blocks_then_crash_release_lets_successor_proceed() {
let stub = spawn_stub(false).await; // refresh succeeds once the lock is free
@@ -2634,7 +2488,6 @@ struct Worker {
#[derive(Deserialize)]
struct WorkerOutcome {
result: String,
#[cfg(unix)]
bearer: Option<String>,
launches: u64,
}
@@ -2761,7 +2614,6 @@ async fn test_crossprocess_userinitiated_denial_shared_with_waiting_auto() {
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_crossprocess_two_coordinators_race_to_one_grant_and_cache() {
// Two real coordinator processes race on one key from a cold cache. They
@@ -2852,7 +2704,6 @@ async fn test_crossprocess_two_coordinators_race_to_one_grant_and_cache() {
// second process detect that the predecessor completed while it was waiting
// and adopt its failure directly.
#[cfg(unix)]
#[tokio::test]
async fn test_crossprocess_waiting_headless_adopts_predecessor_refresh_rejected() {
// Two real headless processes on one key. The cache holds an expired
@@ -3088,9 +2939,6 @@ async fn test_crossprocess_post_failure_userinitiated_runs_own_attempt() {
// 6. C (UserInitiated, approve-scripted) starts fresh. C's snapshot == gen
// on disk (1 with fix, 2 with mutation). In both cases C sees no advance
// and runs its own browser flow. code_grants increments by 1 for C.
//
// This test is cache-free (no seed_cache / disk-token assertions) so it runs
// on Windows as well as Unix.
#[tokio::test]
async fn test_crossprocess_adopter_does_not_advance_generation() {
@@ -3215,7 +3063,6 @@ async fn test_crossprocess_adopter_does_not_advance_generation() {
// Mutation check (no digest gating): B adopts A's RefreshRejected →
// refresh_grants stays at 1 → `refresh_grants == 2` assertion FAILS.
#[cfg(unix)]
#[tokio::test]
async fn test_crossprocess_waiter_with_different_rejected_does_not_adopt_leaders_failure() {
// Stub stickily returns "X" but holds each response until released.
@@ -3316,95 +3163,3 @@ async fn test_crossprocess_waiter_with_different_rejected_does_not_adopt_leaders
"both workers run their own refresh — digest mismatch prevented adoption"
);
}
// ---- P1-3 non-Unix read path disabled -----------------------------------
//
// On non-Unix platforms (Windows) token files written by older builds with
// default ACLs should not be consumed by new builds. `read_private_cache`
// returns an error on non-Unix (and opportunistically removes the legacy
// file), so `read_cache` yields `None` and the source behaves as if no
// cached token exists — memory-only cache on non-Unix.
//
// This test uses a cfg-gated stub: on Unix it only exercises the Unix read
// path (as a sanity check); the Windows behavior is proved by the
// `#[cfg(not(unix))]` branch of `read_private_cache` and verified by the
// Windows CI build + manual testing on the Windows runner. The test is written
// to compile on all platforms and asserts the platform-appropriate invariant.
#[tokio::test]
async fn test_non_unix_does_not_serve_legacy_on_disk_token() {
// Seed a token that would be served from disk on Unix (unexpired, valid).
let stub = spawn_stub(false).await; // fresh token on refresh/browser
let cache = TempDir::new().unwrap();
let opener = ScriptedOpener::new(Script::Approve);
let cfg = config(&stub, "/disco/a", cache.path());
seed_cache(
&cfg,
cache.path(),
json!({
"access_token": "legacy-windows-token",
"refresh_token": "legacy-refresh",
"expires_at": future_secs(),
}),
);
let src = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap();
#[cfg(unix)]
{
// On Unix the cache is read and served directly from disk — this is the
// expected behavior on a secured platform.
let token = src
.acquire_with_intent(AuthIntent::Headless, None)
.await
.expect("Unix serves the seeded token from disk");
assert_eq!(token, "legacy-windows-token", "Unix: disk token served");
assert_eq!(
stub.refresh_grants.load(Ordering::SeqCst),
0,
"Unix: no refresh — the disk token was served directly"
);
// The seeded file is still on disk (not removed on Unix).
assert!(
cache_file_path(&cfg, cache.path()).exists(),
"Unix: the cache file is preserved"
);
}
#[cfg(not(unix))]
{
// On non-Unix `read_private_cache` refuses to read the legacy file and
// attempts to remove it. Construction and bearer() behave as if no cache
// exists — the source falls through to a browser flow.
let token = src
.acquire_with_intent(AuthIntent::Auto, None)
.await
.expect("non-Unix: browser flow succeeds (no disk token served)");
assert_ne!(
token, "legacy-windows-token",
"non-Unix: legacy token must not be served from disk"
);
assert_eq!(
stub.code_grants.load(Ordering::SeqCst),
1,
"non-Unix: browser flow ran — disk token was not served"
);
assert_eq!(
stub.refresh_grants.load(Ordering::SeqCst),
0,
"non-Unix: no refresh grant — the source went straight to the browser flow"
);
// The legacy file should have been removed by read_private_cache.
assert!(
!cache_file_path(&cfg, cache.path()).exists(),
"non-Unix: legacy cache file is removed by read_private_cache"
);
// No new token file was written (persist is a no-op on non-Unix).
// (The token is held in memory only.)
assert!(
!cache_file_path(&cfg, cache.path()).exists(),
"non-Unix: no new cache file created (memory-only)"
);
}
}

View File

@@ -0,0 +1,20 @@
[package]
name = "buzz-private-file"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Owner-only files on Windows, keeping Win32 security FFI out of buzz-agent"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Storage_FileSystem",
"Win32_System_Threading",
] }
[target.'cfg(windows)'.dev-dependencies]
tempfile = "3"

View File

@@ -0,0 +1,365 @@
//! Owner-only files on Windows.
//!
//! `buzz-agent` forbids `unsafe`, but keeping its OAuth token cache private on
//! Windows takes Win32 security calls. They live here behind a safe API so that
//! crate keeps its guarantee. Unix gets the same property from file modes in
//! std, so this crate is empty there.
#![cfg(windows)]
#![deny(unsafe_code)]
use std::ffi::c_void;
use std::fs::{File, OpenOptions};
use std::io;
use std::os::windows::ffi::OsStrExt;
use std::os::windows::fs::OpenOptionsExt;
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use std::path::Path;
use std::ptr::{null, null_mut};
use windows_sys::Win32::Foundation::{
LocalFree, ERROR_SUCCESS, GENERIC_WRITE, INVALID_HANDLE_VALUE, WIN32_ERROR,
};
use windows_sys::Win32::Security::Authorization::{
ConvertStringSecurityDescriptorToSecurityDescriptorW, GetSecurityInfo, SetSecurityInfo,
SDDL_REVISION_1, SE_FILE_OBJECT,
};
use windows_sys::Win32::Security::{
EqualSid, GetSecurityDescriptorDacl, GetTokenInformation, TokenOwner,
DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION,
SECURITY_ATTRIBUTES, TOKEN_OWNER, TOKEN_QUERY,
};
use windows_sys::Win32::Storage::FileSystem::{
CreateFileW, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, FILE_FLAG_OPEN_REPARSE_POINT,
FILE_GENERIC_READ, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, WRITE_DAC,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
/// Full access for the file's owner and nobody else; `P` blocks inherited ACEs.
const OWNER_ONLY: &str = "D:P(A;;FA;;;OW)";
/// Create `path` for writing, owner-only from the moment it exists.
///
/// Fails if `path` exists. The DACL is set atomically at creation, so no other
/// user can open the file even briefly. Read, write and delete sharing match
/// std, so another process can still rename a new file over this path.
#[allow(unsafe_code)]
pub fn create_new(path: &Path) -> io::Result<File> {
let descriptor = owner_only()?;
let path = verbatim(path)?;
let attributes = SECURITY_ATTRIBUTES {
nLength: size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: descriptor.0,
bInheritHandle: 0,
};
// SAFETY: `path` is NUL-terminated and `attributes` points at a descriptor
// that outlives the call.
let handle = unsafe {
CreateFileW(
path.as_ptr(),
GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
&attributes,
CREATE_NEW,
FILE_ATTRIBUTE_NORMAL,
null_mut(),
)
};
if handle == INVALID_HANDLE_VALUE {
return Err(io::Error::last_os_error());
}
// SAFETY: `handle` is a valid file handle that nothing else owns.
Ok(unsafe { File::from_raw_handle(handle) })
}
/// Open an existing owner-only file for reading.
///
/// Does not follow a final symlink or junction, and refuses anything but a
/// regular file owned by this process's default owner, which is the owner
/// [`create_new`] assigns. It then re-applies the owner-only DACL on the open
/// handle, so a file with inherited permissions is tightened before use.
pub fn open_owned(path: &Path) -> io::Result<File> {
let file = OpenOptions::new()
.access_mode(FILE_GENERIC_READ | WRITE_DAC)
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
.open(path)?;
if !file.metadata()?.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"not a regular file",
));
}
if !owned_by_current_process(&file)? {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"file is not owned by the current user",
));
}
restrict_to_owner(&file)?;
Ok(file)
}
/// Memory Windows allocated with `LocalAlloc`, freed on drop.
struct Local(*mut c_void);
impl Drop for Local {
#[allow(unsafe_code)]
fn drop(&mut self) {
// SAFETY: Windows allocated this pointer, and only this drop frees it.
unsafe { LocalFree(self.0) };
}
}
/// [`OWNER_ONLY`] as a security descriptor.
#[allow(unsafe_code)]
fn owner_only() -> io::Result<Local> {
let sddl: Vec<u16> = OWNER_ONLY.encode_utf16().chain(Some(0)).collect();
let mut descriptor = null_mut();
// SAFETY: `sddl` is NUL-terminated; on success Windows allocates `descriptor`.
let ok = unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
sddl.as_ptr(),
SDDL_REVISION_1,
&mut descriptor,
null_mut(),
)
};
if ok == 0 {
return Err(io::Error::last_os_error());
}
Ok(Local(descriptor))
}
/// Replace the DACL of a handle opened with `WRITE_DAC` by [`OWNER_ONLY`].
#[allow(unsafe_code)]
fn restrict_to_owner(file: &File) -> io::Result<()> {
let descriptor = owner_only()?;
let (mut present, mut dacl, mut defaulted) = (0, null_mut(), 0);
// SAFETY: `descriptor` is valid, and `dacl` points into it.
if unsafe { GetSecurityDescriptorDacl(descriptor.0, &mut present, &mut dacl, &mut defaulted) }
== 0
{
return Err(io::Error::last_os_error());
}
// SAFETY: the handle is open, and `dacl` stays valid while `descriptor` lives.
win32(unsafe {
SetSecurityInfo(
file.as_raw_handle(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
null_mut(),
null_mut(),
dacl,
null(),
)
})
}
/// Whether the file's owner is this process token's default owner.
#[allow(unsafe_code)]
fn owned_by_current_process(file: &File) -> io::Result<bool> {
let (mut owner, mut descriptor) = (null_mut(), null_mut());
// SAFETY: the handle is open with `READ_CONTROL`; on success Windows
// allocates `descriptor` and points `owner` into it.
win32(unsafe {
GetSecurityInfo(
file.as_raw_handle(),
SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION,
&mut owner,
null_mut(),
null_mut(),
null_mut(),
&mut descriptor,
)
})?;
let _descriptor = Local(descriptor);
let mut token = null_mut();
// SAFETY: `GetCurrentProcess` returns a pseudo-handle; on success `token`
// is a new handle.
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: `token` is a valid handle that nothing else owns.
let token = unsafe { OwnedHandle::from_raw_handle(token) };
let mut len = 0;
// SAFETY: a null, zero-length buffer only queries the required size.
unsafe { GetTokenInformation(token.as_raw_handle(), TokenOwner, null_mut(), 0, &mut len) };
// `usize` elements keep the buffer aligned for `TOKEN_OWNER`'s pointer.
let mut buffer = vec![0usize; (len as usize).div_ceil(size_of::<usize>())];
// SAFETY: `buffer` is aligned and at least `len` bytes long.
let ok = unsafe {
GetTokenInformation(
token.as_raw_handle(),
TokenOwner,
buffer.as_mut_ptr().cast(),
len,
&mut len,
)
};
if ok == 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: Windows wrote a `TOKEN_OWNER` whose SID lives in `buffer`, and
// `owner` lives in `_descriptor`; both outlive the comparison.
Ok(unsafe { EqualSid(owner, (*buffer.as_ptr().cast::<TOKEN_OWNER>()).Owner) } != 0)
}
fn win32(status: WIN32_ERROR) -> io::Result<()> {
if status == ERROR_SUCCESS {
Ok(())
} else {
Err(io::Error::from_raw_os_error(status as i32))
}
}
/// `path` as a NUL-terminated wide string. Drive paths get the `\\?\` prefix
/// that std's own file APIs add, so long paths work here too.
fn verbatim(path: &Path) -> io::Result<Vec<u16>> {
let wide: Vec<u16> = std::path::absolute(path)?
.as_os_str()
.encode_wide()
.collect();
if wide.contains(&0) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"path contains NUL",
));
}
let drive = matches!(wide.as_slice(), [_, colon, slash, ..]
if *colon == u16::from(b':') && *slash == u16::from(b'\\'));
let prefix = if drive { r"\\?\" } else { "" };
Ok(prefix.encode_utf16().chain(wide).chain(Some(0)).collect())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::process::Command;
use windows_sys::Win32::Security::Authorization::ConvertSecurityDescriptorToStringSecurityDescriptorW;
/// The file's DACL as SDDL, such as `D:P(A;;FA;;;OW)`.
#[allow(unsafe_code)]
fn dacl(file: &File) -> String {
let mut descriptor = null_mut();
// SAFETY: the handle is open; on success Windows allocates `descriptor`.
let status = unsafe {
GetSecurityInfo(
file.as_raw_handle(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION,
null_mut(),
null_mut(),
null_mut(),
null_mut(),
&mut descriptor,
)
};
win32(status).unwrap();
let descriptor = Local(descriptor);
let (mut sddl, mut len) = (null_mut(), 0);
// SAFETY: `descriptor` is valid; on success Windows allocates `sddl`
// holding `len` UTF-16 units.
let ok = unsafe {
ConvertSecurityDescriptorToStringSecurityDescriptorW(
descriptor.0,
SDDL_REVISION_1,
DACL_SECURITY_INFORMATION,
&mut sddl,
&mut len,
)
};
assert_ne!(ok, 0, "{}", io::Error::last_os_error());
let _sddl = Local(sddl.cast());
// SAFETY: `sddl` holds `len` UTF-16 units.
let units = unsafe { std::slice::from_raw_parts(sddl, len as usize) };
String::from_utf16_lossy(units)
.trim_end_matches('\0')
.to_owned()
}
fn assert_owner_only(file: &File) {
let sddl = dacl(file);
let (flags, aces) = sddl
.strip_prefix("D:")
.and_then(|rest| rest.split_once('('))
.unwrap_or_else(|| panic!("unexpected DACL {sddl}"));
assert!(flags.contains('P'), "DACL is not protected: {sddl}");
assert_eq!(aces, "A;;FA;;;OW)", "DACL is not owner-only: {sddl}");
}
fn read(mut file: File) -> String {
let mut body = String::new();
file.read_to_string(&mut body).unwrap();
body
}
#[test]
fn create_new_is_owner_only_and_exclusive() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token.json");
let mut file = create_new(&path).unwrap();
file.write_all(b"secret").unwrap();
assert_owner_only(&file);
drop(file);
let err = create_new(&path).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(read(open_owned(&path).unwrap()), "secret");
}
#[test]
fn create_new_accepts_long_paths() {
let dir = tempfile::tempdir().unwrap();
let parent = dir.path().join("a".repeat(120)).join("b".repeat(120));
std::fs::create_dir_all(&parent).unwrap();
let path = parent.join("token.json");
create_new(&path).unwrap();
assert_owner_only(&open_owned(&path).unwrap());
}
#[test]
fn open_owned_tightens_inherited_permissions() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token.json");
std::fs::write(&path, "legacy").unwrap();
let file = open_owned(&path).unwrap();
assert_owner_only(&file);
assert_eq!(read(file), "legacy");
}
fn icacls(path: &Path, args: &[&str]) {
let status = Command::new("icacls")
.arg(path)
.args(args)
.status()
.unwrap();
assert!(status.success(), "icacls {args:?} failed; run elevated");
}
#[test]
#[ignore = "needs an elevated shell; CI runs it"]
fn open_owned_refuses_a_file_owned_by_someone_else() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token.json");
std::fs::write(&path, "planted").unwrap();
// Everyone may read and rewrite it, but SYSTEM, not us, owns it.
icacls(&path, &["/grant", "*S-1-1-0:F"]);
icacls(&path, &["/setowner", "*S-1-5-18"]);
let err = open_owned(&path).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
}
#[test]
#[ignore = "needs an elevated shell; CI runs it"]
fn open_owned_does_not_follow_symlinks() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("elsewhere.json");
let link = dir.path().join("token.json");
std::fs::write(&target, "elsewhere").unwrap();
std::os::windows::fs::symlink_file(&target, &link).unwrap();
let err = open_owned(&link).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
}
}

View File

@@ -1076,6 +1076,7 @@ dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"buzz-private-file",
"dirs",
"fs2",
"getrandom 0.4.3",
@@ -1252,6 +1253,13 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "buzz-private-file"
version = "0.1.0"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "buzz-sdk"
version = "0.1.0"