grok-build-upstream-mirror/crates/codegen/xai-grok-shell/src/util/user_identity.rs
grokkybara[bot] 500129c714 Synced from monorepo
Synced from monorepo

Changes:
- Cache growing transcripts on the messages backend
- Tell the model when a wait was clamped instead of re-inviting it
- Stop the stationarity nudge from claiming results are identical
- Deliver the stationarity nudge after the tool result
- Run auth provider commands through the platform shell (fixes Windows)
- Keep monitor tool stdout short and prescriptive
- Use UUIDs for analytics event insert IDs
- Stop crashing at startup when the host runs out of threads
- Delete the current session from within the session
- Add project forking-settings toggle (backend and deploy-time control)
- Reap a session’s bash and background commands when it closes
- Reap a session’s hook child processes when it closes
- Track coding-data consent decisions
- Fail open the access gate to stop false CLI paywalls
- Ship Agent Dashboard user guide
- Enable doom-loop recovery by default
- Kill agent children and the idle inhibitor when the parent process dies
- Fix multi-process credential wipe and orphaned session log writers

Source-Revision: 6372e41d828b8a6ee82c29e01a69e27ec895cca9
2026-07-29 17:17:54 +00:00

904 lines
30 KiB
Rust

//! Resolves and caches the author identity for feedback submissions
//! ([`crate::agent::config::FeedbackUserConfig`]).
use crate::agent::config::FeedbackUserConfig;
use crate::util::subprocess::CommandLog;
use crate::util::subprocess::RunOptions;
use crate::util::subprocess::git_bin;
use crate::util::subprocess::run_detached_with_timeout;
use crate::util::subprocess::shell_c;
use std::env;
use std::time::Duration;
use std::time::Instant;
use strum::IntoEnumIterator;
use tokio::process::Command;
use tokio::sync::Mutex;
use tokio::task::spawn_blocking;
use tracing::warn;
const COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_VALUE_LEN: usize = 256;
/// Incomplete resolutions retry after this; complete ones never expire.
const INCOMPLETE_RESULT_TTL: Duration = Duration::from_secs(5 * 60);
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct ResolvedUserIdentity {
pub name: Option<String>,
pub email: Option<String>,
}
impl ResolvedUserIdentity {
fn slot(&self, field: IdentityField) -> &Option<String> {
match field {
IdentityField::Name => &self.name,
IdentityField::Email => &self.email,
}
}
fn slot_mut(&mut self, field: IdentityField) -> &mut Option<String> {
match field {
IdentityField::Name => &mut self.name,
IdentityField::Email => &mut self.email,
}
}
#[cfg(test)]
fn is_empty(&self) -> bool {
self.name.is_none() && self.email.is_none()
}
}
#[derive(serde::Deserialize)]
struct IdentityCommandOutput {
name: Option<String>,
email: Option<String>,
}
/// Trims and bounds length ([`MAX_VALUE_LEN`]); the server handles
/// character-level sanitization.
fn normalize_value(s: impl AsRef<str>) -> Option<String> {
let trimmed = s.as_ref().trim();
if trimmed.is_empty() {
return None;
}
if trimmed.len() > MAX_VALUE_LEN {
warn!(
len = trimmed.len(),
"feedback identity: resolved value exceeds the length limit; ignoring it"
);
return None;
}
Some(trimmed.to_owned())
}
fn env_value(var: &str) -> Option<String> {
env::var(var).ok().and_then(normalize_value)
}
/// A literal source value (usually `$VAR` / `${VAR}` already expanded at load).
/// A residual `$` is skipped as a likely-unset variable. This over-rejects a
/// rare genuine `$` so an unset variable is never emitted.
fn literal_value(value: &str) -> Option<String> {
let value = normalize_value(value)?;
if value.contains('$') {
warn!("feedback identity: ignoring a source value with an unresolved variable");
return None;
}
Some(value)
}
fn os_user() -> Option<String> {
#[cfg(unix)]
{
// SUDO_USER can appear in an ordinary shell; trust it only when the
// effective user ID is root.
if nix::unistd::geteuid().is_root()
&& let Some(user) = env_value("SUDO_USER")
{
return Some(user);
}
if let Ok(Some(user)) = nix::unistd::User::from_uid(nix::unistd::geteuid())
&& let Some(name) = normalize_value(&user.name)
{
return Some(name);
}
}
env_value("USER")
.or_else(|| env_value("LOGNAME"))
.or_else(|| env_value("USERNAME"))
}
/// Runs `os_user` on a blocking thread with a timeout: the user lookup can
/// block (a directory service) and runs under the cache lock, so a stall must
/// not hold up feedback submissions. On timeout we stop waiting; the thread
/// finishes in the background holding no lock, and the pool reclaims it.
async fn resolve_os_user() -> Option<String> {
match tokio::time::timeout(COMMAND_TIMEOUT, spawn_blocking(os_user)).await {
Ok(Ok(name)) => name,
Ok(Err(join_error)) => {
warn!(error = %join_error, "feedback identity: os_user lookup task failed");
None
}
Err(_elapsed) => {
warn!("feedback identity: os_user lookup timed out");
None
}
}
}
async fn git_global_email() -> Option<String> {
let mut cmd = Command::new(git_bin());
// `--global` only: a repo-local `.git/config` in a cloned repo could
// otherwise supply an attacker-controlled email.
cmd.args(["config", "--global", "user.email"]);
let output = run_detached_with_timeout(
cmd,
COMMAND_TIMEOUT,
RunOptions {
label: "git config --global user.email",
command_log: CommandLog::Redacted,
},
)
.await
.ok()?;
if !output.status.success() {
return None;
}
normalize_value(String::from_utf8_lossy(&output.stdout))
}
/// `None` on any failure; callers fall back to the declarative sources.
async fn run_identity_command(command: &str) -> Option<ResolvedUserIdentity> {
let cmd = shell_c(command);
let output = run_detached_with_timeout(
cmd,
COMMAND_TIMEOUT,
RunOptions {
label: "feedback identity command",
command_log: CommandLog::Redacted,
},
)
.await
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
match serde_json::from_str::<IdentityCommandOutput>(stdout.trim()) {
Ok(parsed) => Some(ResolvedUserIdentity {
name: parsed.name.and_then(normalize_value),
// Require the same address shape as the declarative sources.
email: parsed
.email
.and_then(normalize_value)
.filter(|email| IdentityField::Email.accepts(email)),
}),
Err(e) => {
warn!(error = %e, "feedback identity: command output is not the expected JSON");
None
}
}
}
#[derive(Debug)]
enum SourceToken {
OsUser,
GitEmail,
/// A literal value, usually produced by `$VAR` / `${VAR}` expansion at
/// config load; used as-is, subject to per-field validation.
Literal(String),
}
impl SourceToken {
fn parse(input: &str) -> Option<Self> {
match input.trim() {
"" => None,
"os_user" => Some(Self::OsUser),
"git_email" => Some(Self::GitEmail),
other => Some(Self::Literal(other.to_owned())),
}
}
/// A log-safe label: keyword tokens by name, literals redacted so a
/// configured literal value never reaches the logs.
fn label(&self) -> &'static str {
match self {
Self::OsUser => "os_user",
Self::GitEmail => "git_email",
Self::Literal(_) => "<literal>",
}
}
/// The one field this token may populate, or `None` if it works for any.
fn restricted_field(&self) -> Option<IdentityField> {
match self {
Self::GitEmail => Some(IdentityField::Email),
Self::OsUser | Self::Literal(_) => None,
}
}
async fn resolve(&self) -> Option<String> {
match self {
Self::OsUser => resolve_os_user().await,
Self::GitEmail => git_global_email().await,
Self::Literal(value) => literal_value(value),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::EnumIter)]
enum IdentityField {
Name,
Email,
}
impl IdentityField {
fn key(self) -> &'static str {
match self {
Self::Name => "name",
Self::Email => "email",
}
}
/// Whether a resolved value is valid for this field. Emails must contain
/// `@`; names have no shape requirement.
fn accepts(self, value: &str) -> bool {
match self {
Self::Name => true,
Self::Email => value.contains('@'),
}
}
fn sources(self, cfg: &FeedbackUserConfig) -> &[String] {
match self {
Self::Name => &cfg.name,
Self::Email => &cfg.email,
}
}
/// Whether the config declares a source that could still fill this field
/// later. `email_domain` is excluded: a derived `<name>@<domain>` depends
/// only on the resolved name, so it won't change on retry.
fn can_produce(self, cfg: &FeedbackUserConfig) -> bool {
!self.sources(cfg).is_empty() || cfg.command.is_some()
}
}
/// The first resolved source wins; misplaced or invalid values are logged and skipped.
async fn resolve_sources(entries: &[String], field: IdentityField) -> Option<String> {
for entry in entries {
let Some(token) = SourceToken::parse(entry) else {
continue;
};
if let Some(restricted_field) = token.restricted_field()
&& restricted_field != field
{
warn!(
token = %token.label(),
field = field.key(),
"feedback identity: ignoring a source token that only applies to the `{}` list",
restricted_field.key()
);
continue;
}
if let Some(value) = token.resolve().await {
// Skip a value invalid for this field, such as a bare username in
// the email list.
if !field.accepts(&value) {
warn!(
token = %token.label(),
field = field.key(),
"feedback identity: ignoring a value that is not valid for this field"
);
continue;
}
return Some(value);
}
}
None
}
/// Builds `<name>@<domain>`, used only after every declared email source fails.
fn derive_email(name: &str, email_domain: Option<&str>) -> Option<String> {
if name.contains('@') || name.contains(char::is_whitespace) {
return None;
}
// Tolerate the admin typo `email_domain = "@example.com"`.
let domain = email_domain?.trim();
let domain = domain.strip_prefix('@').unwrap_or(domain);
if domain.is_empty()
|| domain.contains('@')
// Reject an unset `$VAR` left verbatim by config-load expansion (matches
// `literal_value`), so a derived address never embeds an unresolved var.
|| domain.contains('$')
|| domain.contains(char::is_whitespace)
{
return None;
}
normalize_value(format!("{name}@{domain}"))
}
/// May run subprocesses; production callers go through [`cached_identity`].
async fn resolve_identity(cfg: &FeedbackUserConfig) -> ResolvedUserIdentity {
// The command is admin-provided config: trimmed only, never length-checked,
// so long or multi-line commands still run. Its output is bounded below.
let mut identity = match cfg
.command
.as_deref()
.map(str::trim)
.filter(|command| !command.is_empty())
{
Some(command) => run_identity_command(command).await.unwrap_or_default(),
None => ResolvedUserIdentity::default(),
};
for field in IdentityField::iter() {
if identity.slot(field).is_none() {
let resolved = resolve_sources(field.sources(cfg), field).await;
*identity.slot_mut(field) = resolved;
}
}
if identity.email.is_none()
&& let Some(name) = identity.name.as_deref()
{
identity.email = derive_email(name, cfg.email_domain.as_deref());
}
identity
}
struct CacheEntry {
cfg: FeedbackUserConfig,
identity: ResolvedUserIdentity,
resolved_at: Instant,
}
impl CacheEntry {
/// Whether every field this config can populate has a value. Complete
/// entries never expire; incomplete ones are retried after
/// [`INCOMPLETE_RESULT_TTL`] so a source that wasn't ready (git, a slow
/// command) can fill in later.
fn is_complete(&self) -> bool {
IdentityField::iter()
.all(|field| self.identity.slot(field).is_some() || !field.can_produce(&self.cfg))
}
}
/// Single-slot cache keyed on the config; incomplete results expire after
/// [`INCOMPLETE_RESULT_TTL`].
pub(crate) struct IdentityCache {
slot: Mutex<Option<CacheEntry>>,
}
impl IdentityCache {
pub(crate) const fn new() -> Self {
Self {
slot: Mutex::const_new(None),
}
}
/// Holds the lock across resolution so concurrent submissions run a slow
/// command once, not once each. The tradeoff: submissions serialize, and a
/// first-time resolution can take up to the combined source timeouts. The
/// pre-warm at session spawn keeps this off the interactive path.
pub(crate) async fn get(
&self,
cfg: Option<&FeedbackUserConfig>,
) -> Option<ResolvedUserIdentity> {
self.get_at(cfg, Instant::now()).await
}
async fn get_at(
&self,
cfg: Option<&FeedbackUserConfig>,
now: Instant,
) -> Option<ResolvedUserIdentity> {
let cfg = cfg?;
let mut slot = self.slot.lock().await;
if let Some(entry) = slot.as_ref()
&& entry.cfg == *cfg
&& (entry.is_complete()
|| now.saturating_duration_since(entry.resolved_at) < INCOMPLETE_RESULT_TTL)
{
return Some(entry.identity.clone());
}
let mut identity = resolve_identity(cfg).await;
// A same-config re-resolution is a retry to fill missing fields, not to
// drop resolved ones: keep any field a prior attempt resolved when this
// attempt's source transiently fails (an `os_user` stall, a flaky git
// or command).
if let Some(prev) = slot.as_ref().filter(|entry| entry.cfg == *cfg) {
for field in IdentityField::iter() {
if identity.slot(field).is_none()
&& let Some(value) = prev.identity.slot(field)
{
*identity.slot_mut(field) = Some(value.clone());
}
}
}
*slot = Some(CacheEntry {
cfg: cfg.clone(),
identity: identity.clone(),
resolved_at: now,
});
Some(identity)
}
}
impl Default for IdentityCache {
fn default() -> Self {
Self::new()
}
}
static PROCESS_CACHE: IdentityCache = IdentityCache::new();
pub(crate) async fn cached_identity(
cfg: Option<&FeedbackUserConfig>,
) -> Option<ResolvedUserIdentity> {
PROCESS_CACHE.get(cfg).await
}
#[cfg(test)]
mod tests {
use super::*;
use xai_grok_test_support::env::EnvGuard;
fn cfg() -> FeedbackUserConfig {
FeedbackUserConfig::default()
}
/// Runs the async resolver on a throwaway current-thread runtime so unit
/// tests can stay synchronous.
fn resolve(cfg: FeedbackUserConfig) -> ResolvedUserIdentity {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(resolve_identity(&cfg))
}
#[test]
#[serial_test::serial]
fn os_user_resolves_to_current_user() {
let _no_sudo = EnvGuard::unset("SUDO_USER");
let resolved = resolve(FeedbackUserConfig {
name: vec!["os_user".into()],
..cfg()
});
let name = resolved.name.expect("os_user must resolve on CI/dev");
assert!(!name.trim().is_empty());
assert!(resolved.email.is_none());
}
#[cfg(unix)]
#[test]
#[serial_test::serial]
fn os_user_ignores_sudo_user_when_not_root() {
if nix::unistd::geteuid().is_root() {
// Under root the SUDO_USER branch is the correct one.
return;
}
let _sudo = EnvGuard::set("SUDO_USER", "realuser");
let resolved = resolve(FeedbackUserConfig {
name: vec!["os_user".into()],
..cfg()
});
let name = resolved.name.expect("os_user must still resolve");
assert_ne!(
name, "realuser",
"a lingering SUDO_USER must not be trusted without a root euid"
);
}
#[test]
fn first_resolvable_literal_wins() {
// Empty, unexpanded-`$`, and oversized entries are skipped; the first
// usable, trimmed value wins.
let resolved = resolve(FeedbackUserConfig {
name: vec![
"".into(),
"$UNSET".into(),
"x".repeat(300),
" alice ".into(),
],
..cfg()
});
assert_eq!(resolved.name.as_deref(), Some("alice"));
// A list with no usable entry resolves to nothing.
let resolved = resolve(FeedbackUserConfig {
name: vec!["x".repeat(300)],
..cfg()
});
assert_eq!(resolved.name, None);
}
#[test]
#[serial_test::serial]
fn git_email_resolves_from_global_git_config() {
let dir = tempfile::tempdir().unwrap();
let gitconfig = dir.path().join("gitconfig");
std::fs::write(&gitconfig, "[user]\n\temail = admin@example.com\n").unwrap();
let _global = EnvGuard::set("GIT_CONFIG_GLOBAL", &gitconfig);
let resolved = resolve(FeedbackUserConfig {
email: vec!["git_email".into()],
..cfg()
});
assert_eq!(resolved.email.as_deref(), Some("admin@example.com"));
assert_eq!(resolved.name, None);
}
#[test]
#[serial_test::serial]
fn git_email_is_invalid_in_name_list() {
// Point git at a resolvable config so the rejection can't pass vacuously.
let dir = tempfile::tempdir().unwrap();
let gitconfig = dir.path().join("gitconfig");
std::fs::write(&gitconfig, "[user]\n\temail = admin@example.com\n").unwrap();
let _global = EnvGuard::set("GIT_CONFIG_GLOBAL", &gitconfig);
let resolved = resolve(FeedbackUserConfig {
name: vec!["git_email".into()],
..cfg()
});
assert_eq!(resolved.name, None);
}
#[test]
fn non_address_value_is_rejected_from_the_email_list() {
let resolved = resolve(FeedbackUserConfig {
email: vec!["alice".into()],
..cfg()
});
assert_eq!(resolved.email, None);
}
#[test]
fn email_domain_derives_only_when_email_sources_fail() {
// No usable email source: derive from name@domain.
let resolved = resolve(FeedbackUserConfig {
name: vec!["carol".into()],
email: vec!["$UNSET_EMAIL".into()],
email_domain: Some("example.com".into()),
..cfg()
});
assert_eq!(resolved.email.as_deref(), Some("carol@example.com"));
// A resolved email source wins; domain derivation must not override it.
let resolved = resolve(FeedbackUserConfig {
name: vec!["carol".into()],
email: vec!["carol@corp.example".into()],
email_domain: Some("example.com".into()),
..cfg()
});
assert_eq!(resolved.email.as_deref(), Some("carol@corp.example"));
}
#[test]
fn email_domain_derivation_guards() {
// No username at all: nothing to derive from.
let resolved = resolve(FeedbackUserConfig {
email_domain: Some("example.com".into()),
..cfg()
});
assert_eq!(resolved.email, None);
assert!(resolved.is_empty());
// Leading `@` in the domain (admin typo) is tolerated.
let resolved = resolve(FeedbackUserConfig {
name: vec!["carol".into()],
email_domain: Some("@example.com".into()),
..cfg()
});
assert_eq!(resolved.email.as_deref(), Some("carol@example.com"));
// A username containing `@` or whitespace derives nothing.
for bad_user in ["carol@corp", "carol jones"] {
let resolved = resolve(FeedbackUserConfig {
name: vec![bad_user.into()],
email_domain: Some("example.com".into()),
..cfg()
});
assert_eq!(resolved.email, None, "name {bad_user:?}");
}
// A domain with whitespace, a non-leading `@`, or an unresolved `$VAR`
// derives nothing.
for bad_domain in ["example com", "corp@example.com", "@@example.com", "$CORP"] {
let resolved = resolve(FeedbackUserConfig {
name: vec!["carol".into()],
email_domain: Some(bad_domain.into()),
..cfg()
});
assert_eq!(resolved.email, None, "email_domain {bad_domain:?}");
}
}
/// Multi-line and >[`MAX_VALUE_LEN`]-byte commands must reach the shell,
/// not be rejected for exceeding the length limit.
#[test]
fn multi_line_and_long_commands_still_run() {
let multi_line = "true\necho '{\"name\": \"multi-line\"}'";
let resolved = resolve(FeedbackUserConfig {
command: Some(multi_line.into()),
..cfg()
});
assert_eq!(resolved.name.as_deref(), Some("multi-line"));
let long = format!("{}echo '{{\"name\": \"long-cmd\"}}'", "true && ".repeat(40));
assert!(long.len() > MAX_VALUE_LEN);
let resolved = resolve(FeedbackUserConfig {
command: Some(long),
..cfg()
});
assert_eq!(resolved.name.as_deref(), Some("long-cmd"));
}
#[test]
fn command_output_wins_then_falls_back_per_field() {
// A command's emitted fields win over the declarative sources.
let resolved = resolve(FeedbackUserConfig {
name: vec!["os_user".into()],
command: Some(r#"echo '{"name": "from-cmd", "email": "cmd@example.com"}'"#.into()),
..cfg()
});
assert_eq!(resolved.name.as_deref(), Some("from-cmd"));
assert_eq!(resolved.email.as_deref(), Some("cmd@example.com"));
// A field the command omits falls back to the declarative source.
let resolved = resolve(FeedbackUserConfig {
email: vec!["fallback@example.com".into()],
command: Some(r#"echo '{"name": "cmd-user"}'"#.into()),
..cfg()
});
assert_eq!(resolved.name.as_deref(), Some("cmd-user"));
assert_eq!(resolved.email.as_deref(), Some("fallback@example.com"));
// A command-emitted email without `@` is rejected like a literal one, so
// resolution falls back to the declarative email source.
let resolved = resolve(FeedbackUserConfig {
email: vec!["fallback@example.com".into()],
command: Some(r#"echo '{"name": "cmd-user", "email": "not-an-email"}'"#.into()),
..cfg()
});
assert_eq!(resolved.email.as_deref(), Some("fallback@example.com"));
// Whitespace-only command fields count as unresolved too.
let resolved = resolve(FeedbackUserConfig {
command: Some(r#"echo '{"name": " ", "email": ""}'"#.into()),
..cfg()
});
assert!(resolved.is_empty());
}
#[test]
fn command_failure_falls_back_to_declarative_sources() {
for command in [
"/nonexistent/grok-identity-binary", // command not found: sh exits 127
"exit 3", // nonzero exit
"echo not-json", // unparseable stdout
] {
let resolved = resolve(FeedbackUserConfig {
name: vec!["decl-user".into()],
command: Some(command.into()),
..cfg()
});
assert_eq!(
resolved.name.as_deref(),
Some("decl-user"),
"command {command:?} must fall back to declarative sources"
);
}
}
#[tokio::test]
async fn cache_resolves_once_per_config_and_reresolves_on_change() {
let dir = tempfile::tempdir().unwrap();
let counter = dir.path().join("runs");
let command = format!(
"echo run >> {}; echo '{{\"name\": \"cached-user\"}}'",
counter.display()
);
let cache = IdentityCache::new();
assert_eq!(cache.get(None).await, None, "no opt-in, no identity");
let cfg1 = FeedbackUserConfig {
command: Some(command.clone()),
..cfg()
};
let first = cache.get(Some(&cfg1)).await.unwrap();
assert_eq!(first.name.as_deref(), Some("cached-user"));
let second = cache.get(Some(&cfg1)).await.unwrap();
assert_eq!(second, first);
let runs = std::fs::read_to_string(&counter).unwrap();
assert_eq!(runs.lines().count(), 1, "same config must resolve once");
let cfg2 = FeedbackUserConfig {
command: Some(command),
email_domain: Some("example.com".into()),
..cfg()
};
let third = cache.get(Some(&cfg2)).await.unwrap();
assert_eq!(third.email.as_deref(), Some("cached-user@example.com"));
let runs = std::fs::read_to_string(&counter).unwrap();
assert_eq!(runs.lines().count(), 2, "changed config must re-resolve");
}
/// Command emitting `first_json` on its first run and `then_json` after,
/// appending one line per run to the returned counter file.
fn first_then_command(
dir: &std::path::Path,
first_json: &str,
then_json: &str,
) -> (String, std::path::PathBuf) {
let counter = dir.join("runs");
let marker = dir.join("ready");
let command = format!(
"echo run >> {c}; if [ -f {p} ]; then echo '{then_json}'; else touch {p}; echo '{first_json}'; fi",
c = counter.display(),
p = marker.display()
);
(command, counter)
}
fn run_count(counter: &std::path::Path) -> usize {
std::fs::read_to_string(counter).unwrap().lines().count()
}
#[tokio::test]
async fn cache_incomplete_results_expire_after_ttl() {
let dir = tempfile::tempdir().unwrap();
// First run resolves only the username; later runs resolve both.
let (command, counter) = first_then_command(
dir.path(),
r#"{"name": "u1"}"#,
r#"{"name": "u1", "email": "u1@example.com"}"#,
);
let cache = IdentityCache::new();
let cfg1 = FeedbackUserConfig {
command: Some(command),
..cfg()
};
let t0 = Instant::now();
let first = cache.get_at(Some(&cfg1), t0).await.unwrap();
assert_eq!(first.name.as_deref(), Some("u1"));
assert_eq!(first.email, None, "first run leaves email unresolved");
let second = cache
.get_at(Some(&cfg1), t0 + Duration::from_secs(1))
.await
.unwrap();
assert_eq!(second, first);
assert_eq!(
run_count(&counter),
1,
"incomplete result cached within TTL"
);
let third = cache
.get_at(
Some(&cfg1),
t0 + INCOMPLETE_RESULT_TTL + Duration::from_secs(1),
)
.await
.unwrap();
assert_eq!(
third.email.as_deref(),
Some("u1@example.com"),
"an incomplete resolution must expire, not pin the unresolved field"
);
assert_eq!(run_count(&counter), 2);
let fourth = cache
.get_at(
Some(&cfg1),
t0 + INCOMPLETE_RESULT_TTL * 3 + Duration::from_secs(2),
)
.await
.unwrap();
assert_eq!(fourth, third);
assert_eq!(run_count(&counter), 2, "complete results do not expire");
}
#[tokio::test]
async fn cache_retry_keeps_a_previously_resolved_field() {
// First run resolves only the name; the post-TTL retry resolves only the
// email. The retry must not drop the name the first run already resolved.
let dir = tempfile::tempdir().unwrap();
let (command, counter) = first_then_command(
dir.path(),
r#"{"name": "u1"}"#,
r#"{"email": "u1@example.com"}"#,
);
let cache = IdentityCache::new();
let cfg = FeedbackUserConfig {
command: Some(command),
..cfg()
};
let t0 = Instant::now();
let first = cache.get_at(Some(&cfg), t0).await.unwrap();
assert_eq!(first.name.as_deref(), Some("u1"));
assert_eq!(first.email, None);
let second = cache
.get_at(
Some(&cfg),
t0 + INCOMPLETE_RESULT_TTL + Duration::from_secs(1),
)
.await
.unwrap();
assert_eq!(
second.name.as_deref(),
Some("u1"),
"a transient retry failure must not drop the already-resolved name"
);
assert_eq!(second.email.as_deref(), Some("u1@example.com"));
assert_eq!(run_count(&counter), 2);
}
#[test]
fn name_only_config_settles_but_pending_email_source_stays_incomplete() {
// A name-only config can never fill `email`, so a name-resolved entry is
// complete and won't be retried on TTL.
let complete = CacheEntry {
cfg: FeedbackUserConfig {
name: vec!["carol".into()],
..cfg()
},
identity: ResolvedUserIdentity {
name: Some("carol".into()),
email: None,
},
resolved_at: Instant::now(),
};
assert!(complete.is_complete());
// With an email source declared but unresolved, the entry stays
// incomplete and will be retried after the TTL.
let incomplete = CacheEntry {
cfg: FeedbackUserConfig {
name: vec!["carol".into()],
email: vec!["git_email".into()],
..cfg()
},
identity: ResolvedUserIdentity {
name: Some("carol".into()),
email: None,
},
resolved_at: Instant::now(),
};
assert!(!incomplete.is_complete());
}
#[tokio::test]
async fn cache_single_flights_concurrent_resolution() {
// Two concurrent submissions must run the slow command once, not once
// each: the cache holds its lock across resolution so the work isn't
// duplicated.
let dir = tempfile::tempdir().unwrap();
let counter = dir.path().join("runs");
let command = format!(
"echo run >> {}; sleep 0.2; echo '{{\"name\": \"once\", \"email\": \"once@example.com\"}}'",
counter.display()
);
let cache = IdentityCache::new();
let cfg = FeedbackUserConfig {
command: Some(command),
..cfg()
};
let (first, second) = tokio::join!(cache.get(Some(&cfg)), cache.get(Some(&cfg)));
let first = first.unwrap();
assert_eq!(first.name.as_deref(), Some("once"));
assert_eq!(first, second.unwrap());
assert_eq!(
run_count(&counter),
1,
"concurrent submissions resolve once"
);
}
}