fix: credential masking panic and silent token write errors (#81)

Two bugs fixed:

1. `auth_commands.rs` — `gws auth export` masking used `s[..4]` which
   panics on strings shorter than 4 characters, and `s[s.len().min(4)..]`
   which evaluates to `s[4..]` on long strings — showing the entire
   secret instead of masking it. Replaced with a `mask_secret()` helper
   that safely shows only the first 4 and last 4 characters, or "***"
   for short strings.

2. `token_storage.rs` — `save_to_disk` silently discarded the return
   value of `atomic_write_async` with `let _`, causing the function to
   return `Ok(())` even when the write failed. Token persistence failures
   now properly propagate via `?`.
This commit is contained in:
Miguel 🦈
2026-03-04 19:28:28 -08:00
committed by GitHub
parent f84ce37007
commit 4bca6935d6
3 changed files with 34 additions and 12 deletions
+9
View File
@@ -0,0 +1,9 @@
---
"gws": patch
---
fix: credential masking panic and silent token write errors
Fixed `gws auth export` masking which panicked on short strings and showed
the entire secret instead of masking it. Also fixed silent token cache write
failures in `save_to_disk` that returned `Ok(())` even when the write failed.
+24 -11
View File
@@ -19,6 +19,24 @@ use serde_json::json;
use crate::credential_store;
use crate::error::GwsError;
/// Mask a secret string by showing only the first 4 and last 4 characters.
/// Strings with 8 or fewer characters are fully replaced with "***".
fn mask_secret(s: &str) -> String {
const MASK_PREFIX_LEN: usize = 4;
const MASK_SUFFIX_LEN: usize = 4;
const MIN_LEN_FOR_PARTIAL_MASK: usize = MASK_PREFIX_LEN + MASK_SUFFIX_LEN;
if s.len() > MIN_LEN_FOR_PARTIAL_MASK {
format!(
"{}...{}",
&s[..MASK_PREFIX_LEN],
&s[s.len() - MASK_SUFFIX_LEN..]
)
} else {
"***".to_string()
}
}
/// Minimal scopes for first-run login — only core Workspace APIs that never
/// trigger Google's `restricted_client` / unverified-app block.
///
@@ -279,17 +297,12 @@ async fn handle_export(unmasked: bool) -> Result<(), GwsError> {
println!("{contents}");
} else if let Ok(mut creds) = serde_json::from_str::<serde_json::Value>(&contents) {
if let Some(obj) = creds.as_object_mut() {
if let Some(serde_json::Value::String(s)) = obj.get("client_secret") {
obj.insert(
"client_secret".to_string(),
json!(format!("{}...{}", &s[..4], &s[s.len().min(4)..])),
);
}
if let Some(serde_json::Value::String(s)) = obj.get("refresh_token") {
obj.insert(
"refresh_token".to_string(),
json!(format!("{}...{}", &s[..4], &s[s.len().min(4)..])),
);
for key in ["client_secret", "refresh_token"] {
if let Some(val) = obj.get_mut(key) {
if let Some(s) = val.as_str() {
*val = json!(mask_secret(s));
}
}
}
}
println!("{}", serde_json::to_string_pretty(&creds).unwrap());
+1 -1
View File
@@ -61,7 +61,7 @@ impl EncryptedTokenStorage {
}
// Write atomically via a sibling .tmp file + rename.
let _ = crate::fs_util::atomic_write_async(&self.file_path, encrypted.as_slice()).await;
crate::fs_util::atomic_write_async(&self.file_path, encrypted.as_slice()).await?;
Ok(())
}