Synced from monorepo

Changes:
- Classify clipboard delivery confidence
- Add durable session update append
- Scope the xAI session bearer to first-party memory embedding endpoints
- Persist subagent outputs to disk and bound long-lived agent state
- Add MiniSweAgent:bash for mini-swe-agent parity
- Revert taking local sessions off the persistent shell
- Contextual tip recommending grok wrap on SSH sessions
- Voice STT bearer from model BYOK env_key/api_key
- Define exact website policies for sandbox
- Gate unsafe shell environments
- Shared pin hoist; single require_sha gate for marketplace plugins
- Server-signed is-managed claim (closes sidecar-removal downgrade)
- Optional require_sha pin for remote plugin installs
- Show session title and last exchange in the exit resume hint
- Gate shell output redirects
- Warn when fail_closed is present but not a boolean
- Add canonical text editing core (ratatui-textarea)
- Keep execution state out of goal scratch
- Add acknowledged persistence primitives
- Inherit child network restrictions in sandbox
- Fail closed when hook matchers fail to recompile
- Add MCP setup preferences for plugin MCPs
- Gate sourced shell scripts
- Gate file-typed project hooks
- grok wrap: restore terminal modes on child death
- Harden owner-only permissions on auth and MCP credentials
- Create crash dump files with owner-only permissions
- Write the agent_id cache owner-only (0600)
- SessionMetrics mode skips Mixpanel profile sync
- Dashboard: slim live-tail peek
- Yank full queued prompt text, not (+N lines)
- Defeat clock-rollback on the signed managed-config cache
- Stop early session/cancel from overtaking the prompt and wedging the turn slot
- Self-heal a diverged agent entrypoint on startup
- Add matched inference expectations in test-support
- Add AuthSingleFlight cancel/successor gap tests
- Remove consumer from external OTEL allowlist and pin scrub coverage
- Enable /copy in minimal mode
- Surface capacity and API-key detail on 429 errors
- Single-flight interactive auth
- Fix PageUp/PageDown skipping lines behind sticky prompt header
This commit is contained in:
grokkybara[bot] 2026-07-17 14:19:50 +01:00
commit 98c3b2438a
225 changed files with 18836 additions and 7156 deletions

View file

@ -87,6 +87,24 @@ impl AuthCredentialProvider for ShellAuthCredentialProvider {
self.static_credentials.deployment_key.is_none()
}
}
/// Resolves the embedding credentials for `embed_base_url`, attaching the xAI
/// session credential only to xAI-operated endpoints over `https`.
pub(crate) fn embedding_session_credentials(
embed_base_url: &str,
auth_manager: Option<&Arc<AuthManager>>,
api_key_provider: Option<xai_grok_tools::types::SharedApiKeyProvider>,
) -> xai_grok_memory::EndpointScopedCredentials {
let auth_credentials = auth_manager.map(|am| {
Arc::new(ShellAuthCredentialProvider::new(am.clone(), None, None))
as Arc<dyn AuthCredentialProvider>
});
xai_grok_memory::EndpointScopedCredentials::for_endpoint(
embed_base_url,
crate::util::is_xai_api_bearer_url,
auth_credentials,
api_key_provider,
)
}
/// Build a `StorageClient` for proxy uploads (including the high-volume
/// `batch_upload` used for repo context / `repo_changes_dedup`).
///
@ -568,6 +586,31 @@ mod tests {
"snapshot must reflect refreshed token for subsequent apply() calls"
);
}
#[test]
fn embedding_session_credentials_scopes_to_first_party() {
let _guard = EarlyInvalidationGuard::pin_to_default();
let dir = tempfile::tempdir().unwrap();
let mgr = make_manager(
&dir,
Some(make_auth("xai-session-token", ChronoDuration::hours(1))),
);
let api_key_provider: xai_grok_tools::types::SharedApiKeyProvider =
Arc::new(crate::auth::manager::SharedAuthKeyProvider(mgr.clone()));
for denied in ["https://byok.attacker.example/v1", "http://api.x.ai/v1"] {
let resolved =
embedding_session_credentials(denied, Some(&mgr), Some(api_key_provider.clone()));
assert!(
resolved.is_empty(),
"session credentials must not reach {denied}"
);
}
let resolved = embedding_session_credentials(
"https://api.x.ai/v1",
Some(&mgr),
Some(api_key_provider),
);
assert!(!resolved.is_empty());
}
/// Deployment-key path has no recovery (operator owns the bearer).
#[tokio::test]
async fn refresh_after_unauthorized_is_noop_for_deployment_key() {

View file

@ -164,6 +164,9 @@ pub struct AuthManager {
disk_state: RwLock<Option<DiskAuthState>>,
/// See [`Self::cached_disk_api_key`].
static_key_cache: parking_lot::Mutex<Option<StaticKeyCacheEntry>>,
/// Model `api_key` / resolved `env_key` for voice/tools without a session.
/// Not a session token (those live on `inner`). Prefers over disk; env wins.
process_static_api_key: parking_lot::RwLock<Option<String>>,
sleep_gate: SleepGate,
/// Count of in-flight IdP refreshes (the network call only), so a
/// sleep-imminent transition can wait for a refresh straddling suspend to
@ -405,6 +408,7 @@ impl AuthManager {
refresh_notify: Arc::new(tokio::sync::Notify::new()),
disk_state: RwLock::new(disk_state),
static_key_cache: parking_lot::Mutex::new(None),
process_static_api_key: parking_lot::RwLock::new(None),
sleep_gate: SleepGate::default(),
refresh_in_flight: std::sync::atomic::AtomicU32::new(0),
refresh_drain_lock: parking_lot::Mutex::new(()),
@ -2221,11 +2225,8 @@ pub(crate) fn compute_proactive_sleep(this: &AuthManager) -> StdDuration {
}
}
/// Tools + pager voice: session token first, then static API key.
///
/// Static fallthrough (`XAI_API_KEY` / `auth.json` `xai::api_key`) makes voice
/// work on API-key-only setups without OAuth. API-key login already persists
/// the env key to disk.
/// Tools + pager voice bearer. Static: env → process model key → disk.
/// Kill-switch / `preferred_method = oidc` block static keys.
pub(crate) struct SharedAuthKeyProvider(pub Arc<AuthManager>);
impl xai_grok_tools::types::ApiKeyProvider for SharedAuthKeyProvider {
@ -2266,7 +2267,7 @@ fn prefers_static_api_key(am: &AuthManager) -> bool {
)
}
/// Env → `auth.json` `xai::api_key`. Off under kill-switch or `preferred_method = oidc`.
/// Env → process model key → disk. Off under kill-switch / oidc pin.
fn resolve_static_api_key(am: &AuthManager) -> Option<String> {
if am.grok_com_config.api_key_auth_disabled() {
return None;
@ -2278,6 +2279,7 @@ fn resolve_static_api_key(am: &AuthManager) -> Option<String> {
return None;
}
non_empty_key(crate::agent::auth_method::read_xai_api_key_env().ok())
.or_else(|| non_empty_key(am.process_static_api_key.read().clone()))
.or_else(|| am.cached_disk_api_key())
}
@ -2328,6 +2330,12 @@ impl AuthManager {
}
}
}
/// Set the process model key (empty clears). Not for session tokens.
pub fn set_process_static_api_key(&self, key: Option<String>) {
let key = key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty());
*self.process_static_api_key.write() = key;
}
}
fn non_empty_key(key: Option<String>) -> Option<String> {

View file

@ -3586,6 +3586,105 @@ async fn shared_api_key_provider_disk_memo_follows_rewrites() {
assert_eq!(provider.current_api_key_async().await, None);
}
#[tokio::test]
#[serial_test::serial]
async fn process_key_from_model_env_key() {
use crate::agent::config::{Config, resolve_model_list};
use xai_grok_test_support::EnvGuard;
const ENV: &str = "TEST_MODEL_ENV_KEY";
const TOKEN: &str = "model-env-token";
let _xai = EnvGuard::unset("XAI_API_KEY");
let _legacy = EnvGuard::unset("GROK_CODE_XAI_API_KEY");
let _tok = EnvGuard::set(ENV, TOKEN);
let dm = crate::models::default_model();
let cfg = Config::new_from_toml_cfg(
&toml::from_str(&format!(
r#"
[model."{dm}"]
model = "{dm}"
env_key = "{ENV}"
"#
))
.unwrap(),
)
.unwrap();
let key = resolve_model_list(&cfg, None)
.get(dm)
.and_then(|m| m.own_credential())
.unwrap();
let dir = tempfile::tempdir().unwrap();
let mgr = Arc::new(AuthManager::new(dir.path(), GrokComConfig::default()));
assert!(mgr.current().is_none());
mgr.set_process_static_api_key(Some(key));
assert_eq!(
shared_api_key_provider(mgr)
.current_api_key_async()
.await
.as_deref(),
Some(TOKEN)
);
}
#[tokio::test]
#[serial_test::serial]
async fn process_key_precedence() {
use xai_grok_test_support::EnvGuard;
let _xai = EnvGuard::unset("XAI_API_KEY");
let _legacy = EnvGuard::unset("GROK_CODE_XAI_API_KEY");
let dir = tempfile::tempdir().unwrap();
let mgr = Arc::new(AuthManager::new(dir.path(), GrokComConfig::default()));
let provider = shared_api_key_provider(mgr.clone());
assert_eq!(provider.current_api_key_async().await, None);
crate::auth::store_api_key(dir.path(), "disk").unwrap();
assert_eq!(
provider.current_api_key_async().await.as_deref(),
Some("disk")
);
mgr.set_process_static_api_key(Some(" process ".into()));
assert_eq!(
provider.current_api_key_async().await.as_deref(),
Some("process")
);
{
let _key = EnvGuard::set("XAI_API_KEY", "env");
assert_eq!(
provider.current_api_key_async().await.as_deref(),
Some("env")
);
}
mgr.set_process_static_api_key(None);
assert_eq!(
provider.current_api_key_async().await.as_deref(),
Some("disk")
);
let dir_blocked = tempfile::tempdir().unwrap();
let blocked = Arc::new(AuthManager::new(
dir_blocked.path(),
GrokComConfig {
disable_api_key_auth: Some(true),
..GrokComConfig::default()
},
));
blocked.set_process_static_api_key(Some("ignored".into()));
assert_eq!(
shared_api_key_provider(blocked)
.current_api_key_async()
.await,
None
);
}
fn expired_oidc() -> GrokAuth {
GrokAuth {
key: "expired-key".into(),

View file

@ -13,6 +13,7 @@ mod model;
pub mod oidc;
pub(crate) mod recovery;
pub(crate) mod refresh;
pub(crate) mod single_flight;
mod storage;
pub(crate) mod token_type;
pub(crate) use config::LEGACY_AUTH_SCOPE;

View file

@ -0,0 +1,359 @@
//! Single-flight guard for interactive login.
//!
//! At most one device-code / loopback wait runs at a time: starting a new
//! attempt (or an explicit `x.ai/auth/cancel`) cancels the previous one, so
//! remint/retry cannot stack device-code mints.
//!
//! The attempt owns **all** attempt-scoped state — the cancellation token and
//! the code/url channels — so replacing an attempt swaps everything
//! atomically, and a cancelled predecessor that finishes late structurally
//! cannot touch its successor's channels. Generations guard `end()` the same
//! way: a stale finisher must not clear a newer attempt. Client `request_seq`
//! scopes explicit cancels so a delayed `x.ai/auth/cancel` cannot tear down a
//! successor login.
use std::cell::{Cell, RefCell};
use tokio_util::sync::CancellationToken;
use super::flow::AuthUrlInfo;
/// Channels wired between the ACP ext handlers and one interactive auth flow.
/// `None` for headless attempts (no URL to show, no code to paste).
pub(crate) struct AttemptChannels {
/// Forwards pasted codes from `x.ai/auth/submit_code` to the flow.
code_tx: tokio::sync::mpsc::Sender<String>,
/// Yields the auth URL to `x.ai/auth/get_url`. `Option` so
/// [`AuthSingleFlight::take_url_rx`] can move it out while the attempt
/// lives on (one-shot read).
url_rx: Option<tokio::sync::oneshot::Receiver<AuthUrlInfo>>,
}
struct Attempt {
token: CancellationToken,
channels: Option<AttemptChannels>,
/// Pager `request_seq` for this attempt (scopes delayed cancel RPCs).
client_seq: Option<u64>,
}
/// Why [`AuthSingleFlight::submit_code`] failed.
#[derive(Debug)]
pub(crate) enum SubmitCodeError {
/// No interactive attempt is waiting for a code (idle or headless).
NoPendingAttempt,
/// Channel send failed (attempt channels already closed).
SendFailed(tokio::sync::mpsc::error::TrySendError<String>),
}
#[derive(Default)]
pub(crate) struct AuthSingleFlight {
active: RefCell<Option<Attempt>>,
generation: Cell<u64>,
}
/// RAII end for a [`AuthSingleFlight::begin`] generation: calls [`AuthSingleFlight::end`]
/// on drop so an aborted authenticate future cannot leak attempt state.
pub(crate) struct AuthAttemptGuard<'a> {
sf: &'a AuthSingleFlight,
generation: u64,
ended: Cell<bool>,
}
impl AuthAttemptGuard<'_> {
/// Explicit end (same as drop). Idempotent.
pub(crate) fn end(&self) {
if !self.ended.replace(true) {
self.sf.end(self.generation);
}
}
#[cfg(test)]
pub(crate) fn generation(&self) -> u64 {
self.generation
}
}
impl Drop for AuthAttemptGuard<'_> {
fn drop(&mut self) {
self.end();
}
}
impl AuthSingleFlight {
/// Start a new attempt, cancelling any prior in-flight one. Returns the
/// new attempt's token and an [`AuthAttemptGuard`] that ends this generation
/// on drop (pass no separate `end` — the guard is the only closer).
///
/// `client_seq` is the pager auth `request_seq` (when known); used by
/// [`Self::cancel_for_client_seq`] so a delayed cancel cannot kill a
/// successor attempt.
pub(crate) fn begin(
&self,
channels: Option<AttemptChannels>,
client_seq: Option<u64>,
) -> (CancellationToken, AuthAttemptGuard<'_>) {
let generation = self.generation.get().wrapping_add(1);
self.generation.set(generation);
let token = CancellationToken::new();
if let Some(prev) = self.active.borrow_mut().replace(Attempt {
token: token.clone(),
channels,
client_seq,
}) {
tracing::info!("auth: cancelling prior interactive auth for single-flight");
prev.token.cancel();
}
(
token,
AuthAttemptGuard {
sf: self,
generation,
ended: Cell::new(false),
},
)
}
/// Finish an attempt: drops its token *and channels* only if `generation`
/// is still the active one (a stale finisher must not clear a newer
/// attempt's state).
pub(crate) fn end(&self, generation: u64) {
if self.generation.get() == generation {
*self.active.borrow_mut() = None;
}
}
/// Cancel the active attempt, if any. Idempotent. Prefer
/// [`Self::cancel_for_client_seq`] when the caller has a pager `request_seq`
/// so a delayed cancel cannot tear down a newer login.
pub(crate) fn cancel(&self) {
if let Some(prev) = self.active.borrow_mut().take() {
tracing::info!("auth: interactive auth cancelled");
prev.token.cancel();
}
}
/// Cancel only if the active attempt was started for `client_seq`. A stale
/// cancel (successor already began) is a no-op.
pub(crate) fn cancel_for_client_seq(&self, client_seq: u64) {
let mut active = self.active.borrow_mut();
match active.as_ref() {
Some(a) if a.client_seq == Some(client_seq) => {
if let Some(prev) = active.take() {
tracing::info!(
client_seq,
"auth: interactive auth cancelled for client request_seq"
);
prev.token.cancel();
}
}
Some(a) => {
tracing::debug!(
client_seq,
active_client_seq = ?a.client_seq,
"auth: ignoring stale cancel for superseded request_seq"
);
}
None => {
tracing::debug!(
client_seq,
"auth: cancel_for_client_seq with no active attempt"
);
}
}
}
/// Forward a pasted code to the active attempt's flow.
pub(crate) fn submit_code(&self, code: String) -> Result<(), SubmitCodeError> {
match self
.active
.borrow()
.as_ref()
.and_then(|a| a.channels.as_ref())
{
Some(ch) => ch
.code_tx
.try_send(code)
.map_err(SubmitCodeError::SendFailed),
None => Err(SubmitCodeError::NoPendingAttempt),
}
}
/// Take the active attempt's URL receiver (one-shot; subsequent calls
/// return `None`, as does an idle or headless attempt).
pub(crate) fn take_url_rx(&self) -> Option<tokio::sync::oneshot::Receiver<AuthUrlInfo>> {
self.active
.borrow_mut()
.as_mut()
.and_then(|a| a.channels.as_mut().and_then(|ch| ch.url_rx.take()))
}
}
impl AttemptChannels {
pub(crate) fn new(
code_tx: tokio::sync::mpsc::Sender<String>,
url_rx: tokio::sync::oneshot::Receiver<AuthUrlInfo>,
) -> Self {
Self {
code_tx,
url_rx: Some(url_rx),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn channels() -> (AttemptChannels, tokio::sync::mpsc::Receiver<String>) {
let (code_tx, code_rx) = tokio::sync::mpsc::channel(1);
let (_url_tx, url_rx) = tokio::sync::oneshot::channel();
(AttemptChannels::new(code_tx, url_rx), code_rx)
}
#[test]
fn begin_cancels_prior_attempt() {
let sf = AuthSingleFlight::default();
let (first, _g1) = sf.begin(None, None);
let (second, _g2) = sf.begin(None, None);
assert!(first.is_cancelled(), "prior attempt must be cancelled");
assert!(!second.is_cancelled(), "new attempt must be live");
}
#[test]
fn cancel_stops_active_attempt_and_is_idempotent() {
let sf = AuthSingleFlight::default();
let (token, _g) = sf.begin(None, None);
sf.cancel();
assert!(token.is_cancelled());
sf.cancel(); // no active attempt — must not panic
}
#[test]
fn stale_end_does_not_clear_newer_attempt() {
let sf = AuthSingleFlight::default();
let (_first, first_guard) = sf.begin(None, None);
let first_gen = first_guard.generation();
// Keep first_guard alive but end via generation (stale after second begin).
let (second, _second_guard) = sf.begin(None, None);
sf.end(first_gen); // stale finisher
sf.cancel(); // must still cancel the second attempt's token
assert!(
second.is_cancelled(),
"stale end() must not have cleared the active token"
);
}
#[test]
fn current_end_drops_the_stored_attempt() {
let sf = AuthSingleFlight::default();
let (token, guard) = sf.begin(None, None);
guard.end();
sf.cancel(); // nothing active — must not cancel the finished attempt
assert!(!token.is_cancelled());
}
/// The race the attempt object exists to prevent: a cancelled
/// predecessor finishing late must not drop the successor's channels.
#[test]
fn stale_end_leaves_successor_channels_intact() {
let sf = AuthSingleFlight::default();
let (_first, first_guard) = sf.begin(None, None);
let first_gen = first_guard.generation();
let (ch, mut code_rx) = channels();
let (_second, _g2) = sf.begin(Some(ch), Some(2));
sf.end(first_gen); // stale finisher (attempt #1's cleanup)
sf.submit_code("1234".into())
.expect("successor's code channel must still be wired");
assert_eq!(code_rx.try_recv().as_deref(), Ok("1234"));
assert!(
sf.take_url_rx().is_some(),
"successor's url receiver must still be present"
);
}
#[test]
fn submit_code_and_url_rx_absent_when_idle_or_headless() {
let sf = AuthSingleFlight::default();
assert!(
matches!(
sf.submit_code("x".into()),
Err(SubmitCodeError::NoPendingAttempt)
),
"idle: no attempt is waiting for a code"
);
assert!(sf.take_url_rx().is_none());
let _g = sf.begin(None, None); // headless attempt: token only
assert!(
matches!(
sf.submit_code("x".into()),
Err(SubmitCodeError::NoPendingAttempt)
),
"headless: no channels"
);
assert!(sf.take_url_rx().is_none());
}
#[test]
fn cancel_for_client_seq_ignores_stale_seq() {
let sf = AuthSingleFlight::default();
let (first, _g1) = sf.begin(None, Some(1));
let (second, _g2) = sf.begin(None, Some(2));
assert!(first.is_cancelled());
sf.cancel_for_client_seq(1); // delayed cancel for attempt 1
assert!(
!second.is_cancelled(),
"stale cancel must not tear down the successor"
);
sf.cancel_for_client_seq(2);
assert!(second.is_cancelled());
}
#[test]
fn attempt_guard_ends_on_drop() {
let sf = AuthSingleFlight::default();
let (token, guard) = sf.begin(None, Some(7));
drop(guard);
sf.cancel(); // nothing active
assert!(!token.is_cancelled());
assert!(matches!(
sf.submit_code("x".into()),
Err(SubmitCodeError::NoPendingAttempt)
));
}
/// Headless (and interactive) authenticate `select!`s on this token —
/// cancel must interrupt a long wait rather than leaving it racing
/// (logout / unscoped cancel path).
#[tokio::test]
async fn cancel_interrupts_waiting_select() {
let sf = AuthSingleFlight::default();
let (cancel, _guard) = sf.begin(None, Some(42)); // headless: no channels
let waiter = tokio::spawn(async move {
tokio::select! {
biased;
_ = cancel.cancelled() => "cancelled",
_ = tokio::time::sleep(std::time::Duration::from_secs(30)) => "timeout",
}
});
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
sf.cancel(); // same as handle_logout / unscoped cancel
assert_eq!(waiter.await.expect("join"), "cancelled");
}
/// Logout-style unscoped cancel, then a new begin must not see prior channels.
#[test]
fn cancel_then_begin_is_clean_for_successor() {
let sf = AuthSingleFlight::default();
let (ch, mut code_rx) = channels();
let (old, _g) = sf.begin(Some(ch), Some(1));
sf.cancel();
assert!(old.is_cancelled());
let (ch2, mut code_rx2) = channels();
let (new, _g2) = sf.begin(Some(ch2), Some(2));
assert!(!new.is_cancelled());
sf.submit_code("ok".into()).expect("successor wired");
assert_eq!(code_rx2.try_recv().as_deref(), Ok("ok"));
assert!(code_rx.try_recv().is_err(), "prior channel must be dead");
}
}

View file

@ -52,6 +52,16 @@ pub fn read_auth_json(auth_file: &Path) -> std::io::Result<AuthStore> {
let mut contents = String::new();
file.read_to_string(&mut contents)?;
// Tighten world-readable copies (hand-restored, umask edge cases, etc.).
// Best-effort: a chmod failure must not block login/read paths.
if let Err(e) = crate::util::secure_file::ensure_owner_only_permissions(auth_file) {
tracing::warn!(
path = %auth_file.display(),
error = %e,
"auth: failed to enforce owner-only permissions on auth.json"
);
}
// Empty files are valid (recover from prior crash/partial write).
let trimmed = contents.trim();
if trimmed.is_empty() {
@ -114,6 +124,8 @@ pub(crate) fn backup_corrupt_auth_file(path: &Path) -> Option<PathBuf> {
match std::fs::rename(path, &backup) {
Ok(()) => {
// Corrupt backups still hold token material — keep them owner-only.
let _ = crate::util::secure_file::ensure_owner_only_permissions(&backup);
tracing::warn!(
original = %path.display(),
backup = %backup.display(),
@ -246,9 +258,17 @@ fn write_store_to(path: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
.into_inner()
.map_err(|e| e.into_error())?
.sync_all()?;
#[cfg(windows)]
{
crate::util::secure_file::set_windows_secure_permissions(path)?;
// `open_secure_file` mode bits apply only on create; tighten existing paths.
// Best-effort after durable content: a chmod-only failure must not look
// like a failed write. The in-place fallback restores the prior snapshot
// on any `write_store_to` Err, which would discard freshly written tokens.
// Load path re-tightens on next read.
if let Err(e) = crate::util::secure_file::ensure_owner_only_permissions(path) {
tracing::warn!(
error = %e,
path = %path.display(),
"auth: failed to ensure owner-only permissions after write"
);
}
Ok(())
}
@ -263,6 +283,15 @@ fn write_auth_json_atomic(auth_file: &Path, auth_store: &AuthStore) -> std::io::
let _ = std::fs::remove_file(auth_file);
}
std::fs::rename(&tmp, auth_file)?;
// Re-assert on the final path (covers rename edge cases / FS quirks).
// Best-effort: rename already published the new tokens.
if let Err(e) = crate::util::secure_file::ensure_owner_only_permissions(auth_file) {
tracing::warn!(
error = %e,
path = %auth_file.display(),
"auth: failed to ensure owner-only permissions after rename"
);
}
Ok(())
}
@ -317,10 +346,7 @@ fn restore_prior_bytes(auth_file: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut file = open_secure_file(auth_file)?;
file.write_all(bytes)?;
file.sync_all()?;
#[cfg(windows)]
{
crate::util::secure_file::set_windows_secure_permissions(auth_file)?;
}
crate::util::secure_file::ensure_owner_only_permissions(auth_file)?;
Ok(())
}
@ -433,6 +459,46 @@ mod write_fallback_tests {
assert_eq!(mode & 0o777, 0o600, "in-place write must stay 0o600");
}
#[cfg(unix)]
#[test]
fn write_tightens_preexisting_world_readable_auth_json() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
std::fs::write(&path, b"{}").unwrap();
let mut loose = std::fs::metadata(&path).unwrap().permissions();
loose.set_mode(0o644);
std::fs::set_permissions(&path, loose).unwrap();
write_auth_json(&path, &sample_store()).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(
mode & 0o777,
0o600,
"rewrite must tighten preexisting open perms"
);
}
#[cfg(unix)]
#[test]
fn read_tightens_world_readable_auth_json() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
write_auth_json(&path, &sample_store()).unwrap();
let mut loose = std::fs::metadata(&path).unwrap().permissions();
loose.set_mode(0o644);
std::fs::set_permissions(&path, loose).unwrap();
let _ = read_auth_json(&path).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(
mode & 0o777,
0o600,
"load must tighten open auth.json perms"
);
}
/// A `StorageFull` (ENOSPC) failure on the atomic path must fall back to
/// the in-place write so the credential still lands on disk.
#[test]