#![cfg(feature = "daemon")]
use std::io::{IsTerminal, Read};
use std::path::{Path, PathBuf};
use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::QueryOutputFormat;
use crate::render::{AnsiColors, ansi_colors, no_colors};
const ENV_DAEMON_API_KEY: &str = "PERF_SENTINEL_DAEMON_API_KEY";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const USER_AGENT: &str = "perf-sentinel-ack";
const MAX_ACKS_RESPONSE: usize = sentinel_core::daemon::query_api::MAX_ACKS_RESPONSE;
#[derive(clap::Subcommand)]
pub(crate) enum AckAction {
Create {
#[arg(short, long)]
signature: Option<String>,
#[arg(short, long)]
reason: String,
#[arg(long, value_name = "ISO8601_OR_DURATION")]
expires: Option<String>,
#[arg(long)]
by: Option<String>,
#[arg(long, value_name = "PATH")]
api_key_file: Option<PathBuf>,
},
Revoke {
#[arg(short, long)]
signature: Option<String>,
#[arg(long, value_name = "PATH")]
api_key_file: Option<PathBuf>,
},
List {
#[arg(long, value_enum, default_value = "text")]
output: QueryOutputFormat,
#[arg(long, value_name = "PATH")]
api_key_file: Option<PathBuf>,
},
}
#[derive(Serialize)]
struct AckRequestBody {
#[serde(skip_serializing_if = "Option::is_none")]
by: Option<String>,
reason: String,
#[serde(skip_serializing_if = "Option::is_none")]
expires_at: Option<DateTime<Utc>>,
}
#[derive(Deserialize)]
struct AckListEntry {
signature: String,
by: String,
#[serde(default)]
reason: Option<String>,
at: DateTime<Utc>,
#[serde(default)]
expires_at: Option<DateTime<Utc>>,
}
pub(crate) async fn cmd_ack(daemon_url: &str, action: AckAction) -> i32 {
let base = match validate_url(daemon_url) {
Ok(s) => s,
Err(e) => {
eprintln!("{e}");
return 1;
}
};
match action {
AckAction::Create {
signature,
reason,
expires,
by,
api_key_file,
} => {
run_create(
&base,
signature,
reason,
expires,
by,
api_key_file.as_deref(),
)
.await
}
AckAction::Revoke {
signature,
api_key_file,
} => run_revoke(&base, signature, api_key_file.as_deref()).await,
AckAction::List {
output,
api_key_file,
} => run_list(&base, output, api_key_file.as_deref()).await,
}
}
async fn run_create(
base: &str,
signature: Option<String>,
reason: String,
expires: Option<String>,
by: Option<String>,
api_key_file: Option<&Path>,
) -> i32 {
let signature = match resolve_signature(signature) {
Ok(s) => s,
Err(e) => {
eprintln!("{e}");
return 1;
}
};
let expires_at = match expires.as_deref().map(parse_expires).transpose() {
Ok(v) => v,
Err(e) => {
eprintln!("Error: invalid --expires value, {e}");
return 1;
}
};
let resolved_by = resolve_by(by);
let api_key = match resolve_api_key(api_key_file) {
Ok(v) => v,
Err(e) => {
eprintln!("{e}");
return 1;
}
};
let body = AckRequestBody {
by: Some(resolved_by.clone()),
reason: reason.clone(),
expires_at,
};
let payload = match serde_json::to_vec(&body) {
Ok(v) => bytes::Bytes::from(v),
Err(e) => {
eprintln!("Error: cannot encode request body, {e}");
return 1;
}
};
let client = sentinel_core::http_client::build_client_with_body();
let url = format!("{base}/api/findings/{signature}/ack");
let (status, _body) = match call_with_tty_retry(
&client,
hyper::Method::POST,
&url,
api_key.as_deref(),
payload,
)
.await
{
Ok(v) => v,
Err(e) => {
eprint_network_error(base, &e);
return 1;
}
};
finish_create(status, &signature, &resolved_by, &reason, expires_at, base)
}
fn finish_create(
status: hyper::StatusCode,
signature: &str,
by: &str,
reason: &str,
expires_at: Option<DateTime<Utc>>,
daemon_url: &str,
) -> i32 {
if status.as_u16() == 201 {
print_create_summary(signature, by, reason, expires_at);
0
} else {
eprint_status_error(status, "create", signature, daemon_url);
exit_code_for_status(status)
}
}
async fn run_revoke(base: &str, signature: Option<String>, api_key_file: Option<&Path>) -> i32 {
let signature = match resolve_signature(signature) {
Ok(s) => s,
Err(e) => {
eprintln!("{e}");
return 1;
}
};
let api_key = match resolve_api_key(api_key_file) {
Ok(v) => v,
Err(e) => {
eprintln!("{e}");
return 1;
}
};
let client = sentinel_core::http_client::build_client_with_body();
let url = format!("{base}/api/findings/{signature}/ack");
let Some((status, _body)) = call_no_body_or_print_error(
&client,
hyper::Method::DELETE,
&url,
api_key.as_deref(),
base,
)
.await
else {
return 1;
};
finish_revoke(status, &signature, base)
}
fn finish_revoke(status: hyper::StatusCode, signature: &str, daemon_url: &str) -> i32 {
if status.as_u16() == 204 {
print_revoke_summary(signature);
0
} else {
eprint_status_error(status, "revoke", signature, daemon_url);
exit_code_for_status(status)
}
}
async fn run_list(base: &str, format: QueryOutputFormat, api_key_file: Option<&Path>) -> i32 {
let api_key = match resolve_api_key(api_key_file) {
Ok(v) => v,
Err(e) => {
eprintln!("{e}");
return 1;
}
};
let client = sentinel_core::http_client::build_client_with_body();
let url = format!("{base}/api/acks");
let Some((status, body)) =
call_no_body_or_print_error(&client, hyper::Method::GET, &url, api_key.as_deref(), base)
.await
else {
return 1;
};
if status.as_u16() != 200 {
eprint_status_error(status, "list", "", base);
return exit_code_for_status(status);
}
match format {
QueryOutputFormat::Json => {
if let Err(e) = print_pretty_json(&body) {
eprintln!("Error: cannot parse daemon response, {e}");
return 1;
}
}
QueryOutputFormat::Text => {
let entries: Vec<AckListEntry> = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(e) => {
eprintln!("Error: cannot parse daemon response, {e}");
return 1;
}
};
print!(
"{}",
format_ack_table(&entries, std::io::stdout().is_terminal())
);
}
}
0
}
async fn http_call(
client: &sentinel_core::http_client::HttpClientWithBody,
method: hyper::Method,
url: &str,
api_key: Option<&str>,
body: bytes::Bytes,
) -> Result<(hyper::StatusCode, bytes::Bytes), sentinel_core::http_client::FetchError> {
let uri: sentinel_core::http_client::Uri =
url.parse().map_err(|e: hyper::http::uri::InvalidUri| {
sentinel_core::http_client::FetchError::BodyRead(format!("invalid URL `{url}`: {e}"))
})?;
sentinel_core::http_client::fetch_with_body(
client,
method,
&uri,
USER_AGENT,
REQUEST_TIMEOUT,
api_key,
body,
)
.await
}
async fn call_with_tty_retry(
client: &sentinel_core::http_client::HttpClientWithBody,
method: hyper::Method,
url: &str,
api_key: Option<&str>,
body: bytes::Bytes,
) -> Result<(hyper::StatusCode, bytes::Bytes), sentinel_core::http_client::FetchError> {
let (status, response_body) =
http_call(client, method.clone(), url, api_key, body.clone()).await?;
if status.as_u16() == 401
&& api_key.is_none()
&& let Some(prompted) = prompt_api_key()
{
return http_call(client, method, url, Some(&prompted), body).await;
}
Ok((status, response_body))
}
async fn call_no_body_or_print_error(
client: &sentinel_core::http_client::HttpClientWithBody,
method: hyper::Method,
url: &str,
api_key: Option<&str>,
daemon_url: &str,
) -> Option<(hyper::StatusCode, bytes::Bytes)> {
match call_with_tty_retry(client, method, url, api_key, bytes::Bytes::new()).await {
Ok(v) => Some(v),
Err(e) => {
eprint_network_error(daemon_url, &e);
None
}
}
}
pub(crate) fn validate_url(daemon_url: &str) -> Result<String, String> {
if daemon_url.is_empty() {
return Err(format!("Invalid daemon URL `{daemon_url}`: empty"));
}
let (scheme_part, rest) = daemon_url.split_once("://").ok_or_else(|| {
format!("Invalid daemon URL `{daemon_url}`: scheme must be http or https")
})?;
let trimmed_rest = rest.trim_end_matches('/');
if trimmed_rest.is_empty() {
return Err(format!("Invalid daemon URL `{daemon_url}`: missing host"));
}
let normalized = format!("{scheme_part}://{trimmed_rest}");
let parsed: sentinel_core::http_client::Uri = normalized
.parse()
.map_err(|e| format!("Invalid daemon URL `{daemon_url}`: {e}"))?;
if !matches!(parsed.scheme_str(), Some("http" | "https")) {
return Err(format!(
"Invalid daemon URL `{daemon_url}`: scheme must be http or https"
));
}
if parsed.host().is_none_or(str::is_empty) {
return Err(format!("Invalid daemon URL `{daemon_url}`: missing host"));
}
if let Some(authority) = parsed.authority()
&& authority.as_str().contains('@')
{
return Err(format!(
"Invalid daemon URL `{daemon_url}`: userinfo (user@host) is not allowed, use --api-key-file or PERF_SENTINEL_DAEMON_API_KEY for auth"
));
}
if !matches!(parsed.path(), "" | "/") {
return Err(format!(
"Invalid daemon URL `{daemon_url}`: path component is not allowed, drop the suffix after the host"
));
}
if parsed.query().is_some() {
return Err(format!(
"Invalid daemon URL `{daemon_url}`: query string is not allowed, drop the `?...` suffix"
));
}
Ok(normalized)
}
fn resolve_signature(arg: Option<String>) -> Result<String, String> {
if let Some(s) = arg {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err("Error: --signature must not be empty".to_string());
}
return Ok(trimmed.to_string());
}
if std::io::stdin().is_terminal() {
return Err(
"Error: --signature is required (stdin is a TTY, cannot read signature from it)"
.to_string(),
);
}
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(|e| format!("Error: cannot read signature from stdin, {e}"))?;
let trimmed = buf.trim();
if trimmed.is_empty() {
return Err("Error: stdin contained no signature".to_string());
}
Ok(trimmed.to_string())
}
fn resolve_api_key(file: Option<&Path>) -> Result<Option<String>, String> {
if let Ok(v) = std::env::var(ENV_DAEMON_API_KEY) {
let trimmed = v.trim();
if !trimmed.is_empty() {
return Ok(Some(trimmed.to_string()));
}
}
if let Some(path) = file {
return read_api_key_file(path).map(Some);
}
Ok(None)
}
fn read_api_key_file(path: &Path) -> Result<String, String> {
use std::fs::OpenOptions;
use std::io::Read as _;
let mut opts = OpenOptions::new();
opts.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
opts.custom_flags(libc::O_NOFOLLOW);
}
let mut file = opts.open(path).map_err(|e| {
format!(
"Error: cannot read --api-key-file `{}`, {e}",
path.display()
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt as _;
if std::io::stderr().is_terminal()
&& let Ok(meta) = file.metadata()
&& meta.mode() & 0o077 != 0
{
eprintln!(
"Warning: --api-key-file `{}` is group/world readable (mode {:o}), consider 'chmod 600'",
path.display(),
meta.mode() & 0o777
);
}
}
let mut raw = String::new();
file.read_to_string(&mut raw).map_err(|e| {
format!(
"Error: cannot read --api-key-file `{}`, {e}",
path.display()
)
})?;
let stripped = raw.trim_end_matches(['\n', '\r']).to_string();
if stripped.is_empty() {
return Err(format!(
"Error: --api-key-file `{}` is empty",
path.display()
));
}
if stripped.bytes().any(|b| b < 0x20 || b == 0x7f) {
return Err(format!(
"Error: --api-key-file `{}` contains control characters",
path.display()
));
}
Ok(stripped)
}
fn prompt_api_key() -> Option<String> {
if !std::io::stdin().is_terminal() {
return None;
}
eprintln!("Daemon requires authentication.");
rpassword::prompt_password("API key (will not echo): ")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn parse_expires(s: &str) -> Result<DateTime<Utc>, String> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err("empty value".to_string());
}
if let Ok(dt) = DateTime::parse_from_rfc3339(trimmed) {
return Ok(dt.with_timezone(&Utc));
}
let dur = humantime::parse_duration(trimmed).map_err(|e| {
format!(
"expected ISO8601 datetime (e.g. 2026-05-11T00:00:00Z) or relative duration (e.g. 7d, 24h, 30m); got `{trimmed}` ({e})"
)
})?;
let chrono_dur =
chrono::Duration::from_std(dur).map_err(|_| "duration overflow".to_string())?;
Utc::now()
.checked_add_signed(chrono_dur)
.ok_or_else(|| "duration overflows DateTime range".to_string())
}
fn resolve_by(arg: Option<String>) -> String {
arg.filter(|s| !s.trim().is_empty())
.or_else(|| std::env::var("USER").ok())
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "anonymous".to_string())
}
fn print_create_summary(
signature: &str,
by: &str,
reason: &str,
expires_at: Option<DateTime<Utc>>,
) {
let colors = if std::io::stdout().is_terminal() {
ansi_colors(false)
} else {
no_colors()
};
let AnsiColors {
bold,
green,
dim,
reset,
..
} = colors;
use sentinel_core::text_safety::sanitize_for_terminal;
println!("{bold}{green}Acknowledgment created{reset}");
println!(
" {dim}Signature:{reset} {}",
sanitize_for_terminal(signature)
);
println!(" {dim}By:{reset} {}", sanitize_for_terminal(by));
println!(" {dim}Reason:{reset} {}", sanitize_for_terminal(reason));
match expires_at {
Some(dt) => {
let delta = dt - Utc::now();
let pretty = format_relative(delta);
println!(
" {dim}Expires:{reset} {} ({})",
dt.format("%Y-%m-%dT%H:%M:%SZ"),
pretty
);
}
None => println!(" {dim}Expires:{reset} never"),
}
}
fn print_revoke_summary(signature: &str) {
let colors = if std::io::stdout().is_terminal() {
ansi_colors(false)
} else {
no_colors()
};
let AnsiColors {
bold,
green,
dim,
reset,
..
} = colors;
use sentinel_core::text_safety::sanitize_for_terminal;
println!("{bold}{green}Acknowledgment revoked{reset}");
println!(
" {dim}Signature:{reset} {}",
sanitize_for_terminal(signature)
);
}
fn print_pretty_json(body: &[u8]) -> Result<(), serde_json::Error> {
let json: serde_json::Value = serde_json::from_slice(body)?;
let pretty = serde_json::to_string_pretty(&json)?;
println!("{pretty}");
Ok(())
}
fn format_ack_table(entries: &[AckListEntry], colored: bool) -> String {
use sentinel_core::text_safety::sanitize_for_terminal;
use std::fmt::Write as _;
let colors = if colored {
ansi_colors(true)
} else {
no_colors()
};
let AnsiColors {
bold, dim, reset, ..
} = colors;
let mut out = String::new();
if entries.is_empty() {
let _ = writeln!(out, "{dim}No active daemon acknowledgments.{reset}");
let _ = writeln!(
out,
"Note: TOML CI acks are not listed, see .perf-sentinel-acknowledgments.toml"
);
return out;
}
struct Row {
signature: String,
by: String,
at: String,
expires: String,
reason: String,
}
let rows: Vec<Row> = entries
.iter()
.map(|e| Row {
signature: sanitize_for_terminal(&e.signature).into_owned(),
by: sanitize_for_terminal(&e.by).into_owned(),
at: e.at.format("%Y-%m-%dT%H:%MZ").to_string(),
expires: match e.expires_at {
Some(dt) => dt.format("%Y-%m-%dT%H:%MZ").to_string(),
None => "never".to_string(),
},
reason: e
.reason
.as_deref()
.map(|r| sanitize_for_terminal(r).into_owned())
.unwrap_or_default(),
})
.collect();
let sig_w = column_width("SIGNATURE", rows.iter().map(|r| r.signature.as_str()));
let by_w = column_width("BY", rows.iter().map(|r| r.by.as_str()));
let at_w = column_width("AT", rows.iter().map(|r| r.at.as_str()));
let exp_w = column_width("EXPIRES_AT", rows.iter().map(|r| r.expires.as_str()));
let _ = writeln!(
out,
"{bold}{:<sig_w$} {:<by_w$} {:<at_w$} {:<exp_w$} REASON{reset}",
"SIGNATURE", "BY", "AT", "EXPIRES_AT"
);
for row in &rows {
let _ = writeln!(
out,
"{:<sig_w$} {:<by_w$} {:<at_w$} {:<exp_w$} {}",
row.signature, row.by, row.at, row.expires, row.reason
);
}
let _ = writeln!(out);
let _ = writeln!(
out,
"{} daemon acknowledgments active (showing up to {})",
entries.len(),
MAX_ACKS_RESPONSE
);
let _ = writeln!(
out,
"{dim}Note: TOML CI acks are not listed, see .perf-sentinel-acknowledgments.toml{reset}"
);
out
}
fn column_width<'a>(header: &str, values: impl IntoIterator<Item = &'a str>) -> usize {
let header_w = header.chars().count();
values
.into_iter()
.map(|v| v.chars().count())
.max()
.map_or(header_w, |max_v| max_v.max(header_w))
}
fn exit_code_for_status(status: hyper::StatusCode) -> i32 {
match status.as_u16() {
s if (400..500).contains(&s) => 2,
s if (500..600).contains(&s) => 3,
_ => 1,
}
}
fn eprint_status_error(status: hyper::StatusCode, op: &str, signature: &str, daemon_url: &str) {
let code = status.as_u16();
match (code, op) {
(401, _) => {
eprintln!("Error: daemon requires authentication (HTTP 401)");
eprintln!("hint: set {ENV_DAEMON_API_KEY} environment variable");
eprintln!("hint: or use --api-key-file <path>");
}
(409, "create") => {
eprintln!("Error: signature already acknowledged (HTTP 409)");
if signature.is_empty() {
eprintln!("hint: use 'perf-sentinel ack revoke --signature <SIG>' first");
} else {
eprintln!("hint: use 'perf-sentinel ack revoke --signature {signature}' first");
}
}
(404, "revoke") => {
eprintln!("Error: no active acknowledgment for this signature (HTTP 404)");
}
(400, _) => {
eprintln!("Error: invalid signature format (HTTP 400)");
}
(507, "create") => {
eprintln!("Error: daemon ack store is full (HTTP 507)");
eprintln!("hint: revoke expired acks or increase [daemon.ack] limits");
}
(503, _) => {
eprintln!("Error: daemon ack store is disabled (HTTP 503)");
eprintln!("hint: set [daemon.ack] enabled = true in the daemon config");
}
_ => {
eprintln!("Error: daemon returned HTTP {code} on {op} (daemon at {daemon_url})");
}
}
}
fn eprint_network_error(daemon_url: &str, err: &sentinel_core::http_client::FetchError) {
eprintln!("Error: cannot reach daemon at {daemon_url}");
eprintln!("caused by: {err}");
eprintln!("hint: is `perf-sentinel watch` running?");
}
fn format_relative(delta: chrono::Duration) -> String {
let total = delta.num_seconds();
if total <= 0 {
return "expired".to_string();
}
let days = total / 86_400;
let hours = (total % 86_400) / 3600;
let minutes = (total % 3600) / 60;
if days > 1 {
format!("in {days} days")
} else if days == 1 {
"in 1 day".to_string()
} else if hours >= 1 {
format!("in {hours}h")
} else if minutes >= 1 {
format!("in {minutes}min")
} else {
"in less than a minute".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_expires_iso8601_returns_absolute() {
let dt = parse_expires("2026-05-11T00:00:00Z").expect("valid");
assert_eq!(
dt.format("%Y-%m-%dT%H:%MZ").to_string(),
"2026-05-11T00:00Z"
);
}
#[test]
fn parse_expires_7d_returns_now_plus_seven_days() {
let dt = parse_expires("7d").expect("valid");
let delta = (dt - Utc::now()).num_seconds();
let seven_days = 7 * 86_400_i64;
assert!(
(seven_days - 5..=seven_days + 5).contains(&delta),
"delta {delta} not within 5s of {seven_days}"
);
}
#[test]
fn parse_expires_24h_relative() {
let dt = parse_expires("24h").expect("valid");
let delta = (dt - Utc::now()).num_seconds();
assert!((86_395..=86_405).contains(&delta));
}
#[test]
fn parse_expires_30m_relative() {
let dt = parse_expires("30m").expect("valid");
let delta = (dt - Utc::now()).num_seconds();
assert!((1795..=1805).contains(&delta));
}
#[test]
fn parse_expires_invalid_returns_error() {
let err = parse_expires("not a date").unwrap_err();
assert!(err.contains("expected ISO8601 datetime"));
assert!(err.contains("relative duration"));
assert!(err.contains("not a date"));
}
#[test]
fn parse_expires_empty_returns_error() {
let err = parse_expires(" ").unwrap_err();
assert!(err.contains("empty"));
}
#[test]
fn validate_url_strips_trailing_slash() {
let s = validate_url("http://localhost:4318/").unwrap();
assert_eq!(s, "http://localhost:4318");
}
#[test]
fn validate_url_rejects_non_http_scheme() {
let err = validate_url("ftp://localhost:4318").unwrap_err();
assert!(err.contains("scheme must be http or https"));
}
#[test]
fn validate_url_rejects_missing_host() {
let err = validate_url("http://").unwrap_err();
assert!(err.contains("missing host"));
}
#[test]
fn validate_url_rejects_port_without_host() {
let err = validate_url("http://:8080").unwrap_err();
assert!(err.contains("missing host"));
}
#[test]
fn validate_url_rejects_userinfo() {
let err = validate_url("http://alice@daemon.local").unwrap_err();
assert!(err.contains("userinfo"));
assert!(err.contains("--api-key-file") || err.contains("PERF_SENTINEL_DAEMON_API_KEY"));
}
#[test]
fn validate_url_rejects_path_component() {
let err = validate_url("https://api.example.com/v1/").unwrap_err();
assert!(err.contains("path component is not allowed"));
}
#[test]
fn validate_url_rejects_query_string() {
let err = validate_url("http://localhost:4318?debug=1").unwrap_err();
assert!(err.contains("query string is not allowed"));
}
#[test]
fn validate_url_accepts_ipv6_literal() {
let ok = validate_url("http://[::1]:8080").unwrap();
assert_eq!(ok, "http://[::1]:8080");
}
#[test]
fn validate_url_accepts_https() {
let s = validate_url("https://daemon.example.com/").unwrap();
assert_eq!(s, "https://daemon.example.com");
}
#[test]
fn read_api_key_file_strips_trailing_newline() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("key");
std::fs::write(&path, "secret123\n").unwrap();
let key = read_api_key_file(&path).unwrap();
assert_eq!(key, "secret123");
}
#[test]
fn read_api_key_file_strips_crlf() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("key");
std::fs::write(&path, "secret123\r\n").unwrap();
let key = read_api_key_file(&path).unwrap();
assert_eq!(key, "secret123");
}
#[test]
fn read_api_key_file_rejects_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("key");
std::fs::write(&path, "").unwrap();
let err = read_api_key_file(&path).unwrap_err();
assert!(err.contains("is empty"));
}
#[test]
fn read_api_key_file_returns_error_on_missing_file() {
let path = Path::new("/nonexistent/path/to/key");
let err = read_api_key_file(path).unwrap_err();
assert!(err.contains("cannot read"));
}
#[test]
fn read_api_key_file_rejects_embedded_newline() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("key");
std::fs::write(&path, "secret\nwith newline").unwrap();
let err = read_api_key_file(&path).unwrap_err();
assert!(err.contains("control characters"));
}
#[cfg(unix)]
#[test]
fn read_api_key_file_refuses_to_follow_symlink() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("real_key");
std::fs::write(&target, "secret\n").unwrap();
let link = dir.path().join("link_to_key");
symlink(&target, &link).unwrap();
let err = read_api_key_file(&link).unwrap_err();
assert!(err.contains("cannot read --api-key-file"));
}
#[test]
fn format_ack_table_handles_empty_list() {
let out = format_ack_table(&[], false);
assert!(out.contains("No active daemon acknowledgments"));
assert!(out.contains(".perf-sentinel-acknowledgments.toml"));
}
#[test]
fn format_ack_table_renders_columns_and_count() {
let entries = vec![
AckListEntry {
signature: "n_plus_one_sql:svc:_api:0123456789abcdef".to_string(),
by: "alice".to_string(),
reason: Some("deferred".to_string()),
at: DateTime::parse_from_rfc3339("2026-05-05T12:00:00Z")
.unwrap()
.with_timezone(&Utc),
expires_at: Some(
DateTime::parse_from_rfc3339("2026-05-12T13:30:00Z")
.unwrap()
.with_timezone(&Utc),
),
},
AckListEntry {
signature: "slow_http:other:_api:fedcba9876543210".to_string(),
by: "bob".to_string(),
reason: None,
at: DateTime::parse_from_rfc3339("2026-05-04T09:15:00Z")
.unwrap()
.with_timezone(&Utc),
expires_at: None,
},
];
let out = format_ack_table(&entries, false);
assert!(out.contains("SIGNATURE"));
assert!(out.contains("BY"));
assert!(out.contains("AT"));
assert!(out.contains("EXPIRES_AT"));
assert!(out.contains("REASON"));
assert!(out.contains("alice"));
assert!(out.contains("bob"));
assert!(out.contains("never"));
assert!(out.contains("2026-05-12T13:30Z"));
assert!(out.contains("2 daemon acknowledgments active (showing up to 1000)"));
}
#[test]
fn exit_code_for_status_maps_4xx_to_2() {
assert_eq!(
exit_code_for_status(hyper::StatusCode::from_u16(401).unwrap()),
2
);
assert_eq!(
exit_code_for_status(hyper::StatusCode::from_u16(409).unwrap()),
2
);
assert_eq!(
exit_code_for_status(hyper::StatusCode::from_u16(404).unwrap()),
2
);
assert_eq!(
exit_code_for_status(hyper::StatusCode::from_u16(400).unwrap()),
2
);
}
#[test]
fn exit_code_for_status_maps_5xx_to_3() {
assert_eq!(
exit_code_for_status(hyper::StatusCode::from_u16(500).unwrap()),
3
);
assert_eq!(
exit_code_for_status(hyper::StatusCode::from_u16(503).unwrap()),
3
);
assert_eq!(
exit_code_for_status(hyper::StatusCode::from_u16(507).unwrap()),
3
);
}
#[test]
fn resolve_by_uses_arg_when_present() {
let resolved = resolve_by(Some("alice@example.com".to_string()));
assert_eq!(resolved, "alice@example.com");
}
#[test]
fn resolve_by_treats_blank_arg_as_unset() {
let resolved = resolve_by(Some(" ".to_string()));
assert_ne!(resolved, " ");
}
#[test]
fn format_relative_handles_days_hours_minutes() {
assert_eq!(format_relative(chrono::Duration::days(7)), "in 7 days");
assert_eq!(format_relative(chrono::Duration::days(1)), "in 1 day");
assert_eq!(format_relative(chrono::Duration::hours(3)), "in 3h");
assert_eq!(format_relative(chrono::Duration::minutes(15)), "in 15min");
assert_eq!(format_relative(chrono::Duration::seconds(-1)), "expired");
}
}