use crate::config::Config;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
const CHAIN_CACHE_FILE: &str = "chain_output";
const PULSE_STATE_FILE: &str = "pulse_state";
const PULSE_STATE_TTL_SECONDS: u64 = 10;
const POLL_INTERVAL_MS: u64 = 5;
pub fn run_chain(chain_command: &str, raw_stdin: &str, cfg: &Config, session_key: &str) -> String {
let now_ms = now_millis();
let cache_path = session_cache_file(session_key, CHAIN_CACHE_FILE);
if let Some(path) = cache_path.as_ref() {
if let Some((written_ms, output)) = read_cache_entry(path) {
if is_cache_fresh(written_ms, now_ms, cfg.chain.chain_cache_ttl_seconds) {
return output;
}
}
}
match spawn_with_timeout(chain_command, raw_stdin, cfg.chain.chain_timeout_ms) {
Some(output) => {
let trimmed = trim_trailing_newline(&output);
if let Some(path) = cache_path.as_ref() {
write_cache_entry(path, now_ms, &trimmed);
}
trimmed
}
None => cache_path
.as_ref()
.and_then(read_cache_entry)
.map(|(_, output)| output)
.unwrap_or_default(),
}
}
pub fn read_prev_pulse_state(session_key: &str) -> bool {
let path = match session_cache_file(session_key, PULSE_STATE_FILE) {
Some(path) => path,
None => return false,
};
match read_cache_entry(&path) {
Some((written_ms, value))
if is_cache_fresh(written_ms, now_millis(), PULSE_STATE_TTL_SECONDS) =>
{
value.trim() == "1"
}
_ => false,
}
}
pub fn write_pulse_state(on: bool, session_key: &str) {
if let Some(path) = session_cache_file(session_key, PULSE_STATE_FILE) {
write_cache_entry(&path, now_millis(), if on { "1" } else { "0" });
}
}
pub fn read_named_cache(name: &str) -> Option<(u128, String)> {
let path = cache_file(name)?;
read_cache_entry(&path)
}
pub fn write_named_cache(name: &str, now_ms: u128, payload: &str) {
if let Some(path) = cache_file(name) {
write_cache_entry(&path, now_ms, payload);
}
}
pub fn read_session_named_cache(session_key: &str, name: &str) -> Option<(u128, String)> {
let path = session_cache_file(session_key, name)?;
read_cache_entry(&path)
}
pub fn write_session_named_cache(session_key: &str, name: &str, now_ms: u128, payload: &str) {
if let Some(path) = session_cache_file(session_key, name) {
write_cache_entry(&path, now_ms, payload);
}
}
pub fn cache_now_millis() -> u128 {
now_millis()
}
pub fn is_named_cache_fresh(written_ms: u128, now_ms: u128, ttl_seconds: u64) -> bool {
is_cache_fresh(written_ms, now_ms, ttl_seconds)
}
fn is_cache_fresh(written_ms: u128, now_ms: u128, ttl_seconds: u64) -> bool {
if ttl_seconds == 0 || now_ms < written_ms {
return false;
}
let elapsed_ms = now_ms - written_ms;
elapsed_ms <= (ttl_seconds as u128) * 1000
}
fn cache_dir() -> Option<PathBuf> {
let home = std::env::var_os("HOME").map(PathBuf::from)?;
Some(home.join("Library").join("Caches").join("understatus"))
}
fn cache_file(name: &str) -> Option<PathBuf> {
let safe_name = sanitize_session_key(name);
cache_dir().map(|dir| dir.join(safe_name))
}
pub fn sanitize_session_key(raw: &str) -> String {
let cleaned: String = raw
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.take(64)
.collect();
if cleaned.is_empty() {
if raw.is_empty() {
return "default".to_string();
}
return format!("default-{}", short_hash(raw));
}
if cleaned != raw {
format!("{cleaned}-{}", short_hash(raw))
} else {
cleaned
}
}
fn short_hash(raw: &str) -> String {
let mut hash: u64 = 0xcbf29ce484222325;
for byte in raw.bytes() {
hash ^= byte as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
format!("{:016x}", hash)
}
fn session_cache_file_in(base: &Path, session_key: &str, name: &str) -> PathBuf {
let key = sanitize_session_key(session_key);
let safe_name = sanitize_session_key(name);
base.join("sessions").join(key).join(safe_name)
}
fn session_cache_file(session_key: &str, name: &str) -> Option<PathBuf> {
Some(session_cache_file_in(&cache_dir()?, session_key, name))
}
fn read_cache_entry(path: &PathBuf) -> Option<(u128, String)> {
let contents = std::fs::read_to_string(path).ok()?;
let (timestamp_line, payload) = match contents.split_once('\n') {
Some((first, rest)) => (first, rest.to_string()),
None => (contents.as_str(), String::new()),
};
let written_ms: u128 = timestamp_line.trim().parse().ok()?;
Some((written_ms, payload))
}
fn write_cache_entry(path: &PathBuf, now_ms: u128, payload: &str) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(path, format!("{now_ms}\n{payload}"));
}
fn spawn_with_timeout(command: &str, raw_stdin: &str, timeout_ms: u64) -> Option<String> {
let mut child = Command::new("sh")
.arg("-c")
.arg(command)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(raw_stdin.as_bytes());
}
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
loop {
match child.try_wait() {
Ok(Some(_status)) => {
return collect_stdout(child);
}
Ok(None) => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return None;
}
std::thread::sleep(Duration::from_millis(POLL_INTERVAL_MS));
}
Err(_) => {
let _ = child.kill();
let _ = child.wait();
return None;
}
}
}
}
fn collect_stdout(mut child: std::process::Child) -> Option<String> {
use std::io::Read;
let mut stdout = child.stdout.take()?;
let mut buffer = Vec::new();
stdout.read_to_end(&mut buffer).ok()?;
Some(String::from_utf8_lossy(&buffer).into_owned())
}
fn trim_trailing_newline(value: &str) -> String {
value
.strip_suffix('\n')
.map(|stripped| stripped.strip_suffix('\r').unwrap_or(stripped))
.unwrap_or(value)
.to_string()
}
fn now_millis() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.as_millis())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_freshness_respects_ttl() {
assert!(is_cache_fresh(0, 2_000, 3));
assert!(is_cache_fresh(0, 3_000, 3));
assert!(!is_cache_fresh(0, 3_001, 3));
assert!(!is_cache_fresh(0, 0, 0));
assert!(!is_cache_fresh(5_000, 1_000, 3));
}
#[test]
fn trims_only_trailing_newline() {
assert_eq!(trim_trailing_newline("hello\n"), "hello");
assert_eq!(trim_trailing_newline("hello\r\n"), "hello");
assert_eq!(trim_trailing_newline("a\nb\n"), "a\nb");
assert_eq!(trim_trailing_newline("no-newline"), "no-newline");
assert_eq!(trim_trailing_newline(""), "");
}
#[test]
fn cache_entry_roundtrip() {
let path =
std::env::temp_dir().join(format!("understatus-cache-rt-{}", std::process::id()));
write_cache_entry(&path, 12_345, "line1\nline2");
let (written_ms, payload) = read_cache_entry(&path).expect("캐시 읽기 실패");
assert_eq!(written_ms, 12_345);
assert_eq!(payload, "line1\nline2");
let _ = std::fs::remove_file(&path);
}
#[test]
fn cache_entry_empty_payload() {
let path =
std::env::temp_dir().join(format!("understatus-cache-empty-{}", std::process::id()));
write_cache_entry(&path, 999, "");
let (written_ms, payload) = read_cache_entry(&path).expect("캐시 읽기 실패");
assert_eq!(written_ms, 999);
assert_eq!(payload, "");
let _ = std::fs::remove_file(&path);
}
#[test]
fn missing_cache_returns_none() {
let path = std::env::temp_dir().join("understatus-cache-does-not-exist-xyz");
let _ = std::fs::remove_file(&path);
assert!(read_cache_entry(&path).is_none());
}
#[test]
fn spawn_returns_stdout() {
let output = spawn_with_timeout("printf 'hi-there'", "", 2_000);
assert_eq!(output.as_deref(), Some("hi-there"));
}
#[test]
fn spawn_passes_stdin() {
let output = spawn_with_timeout("cat", "payload-123", 2_000);
assert_eq!(output.as_deref(), Some("payload-123"));
}
#[test]
fn spawn_times_out_quickly() {
let started = Instant::now();
let output = spawn_with_timeout("sleep 5", "", 200);
let elapsed = started.elapsed();
assert_eq!(output, None, "타임아웃 시 None이어야 함");
assert!(
elapsed < Duration::from_secs(2),
"타임아웃이 렌더를 블록함: {elapsed:?}"
);
}
#[test]
fn spawn_nonzero_exit_still_returns_stdout() {
let output = spawn_with_timeout("printf 'partial'; exit 1", "", 2_000);
assert_eq!(output.as_deref(), Some("partial"));
}
#[test]
fn fresh_cache_decision_skips_spawn() {
let path =
std::env::temp_dir().join(format!("understatus-skip-spawn-{}", std::process::id()));
let now = now_millis();
write_cache_entry(&path, now, "CACHED-OUTPUT");
let ttl = Config::default().chain.chain_cache_ttl_seconds; let entry = read_cache_entry(&path).expect("캐시 읽기 실패");
let (written_ms, output) = entry;
let would_skip_spawn = is_cache_fresh(written_ms, now_millis(), ttl);
assert!(would_skip_spawn, "신선 캐시는 자식 스폰을 건너뛰어야 함");
assert_eq!(output, "CACHED-OUTPUT");
let _ = std::fs::remove_file(&path);
}
#[test]
fn stale_cache_decision_triggers_spawn() {
let path =
std::env::temp_dir().join(format!("understatus-stale-spawn-{}", std::process::id()));
let now = now_millis();
write_cache_entry(&path, now.saturating_sub(15_000), "OLD");
let ttl = Config::default().chain.chain_cache_ttl_seconds;
let (written_ms, _output) = read_cache_entry(&path).expect("캐시 읽기 실패");
assert!(
!is_cache_fresh(written_ms, now, ttl),
"만료 캐시는 자식 스폰을 트리거해야 함"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn sanitize_session_key_strips_unsafe() {
for raw in [
"../../etc",
"a/b\\c",
"a b",
"한글-세션",
"x\0y",
"/abs/path",
] {
let key = sanitize_session_key(raw);
assert!(
!key.contains('.') && !key.contains('/') && !key.contains('\\'),
"위험 문자가 남음: {raw:?} → {key:?}"
);
assert!(
key.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
"allowlist 위반: {raw:?} → {key:?}"
);
}
}
#[test]
fn sanitize_session_key_uuid_is_noop() {
let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
assert_eq!(sanitize_session_key(uuid), uuid);
}
#[test]
fn sanitize_session_key_uuid_no_truncation() {
let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
let key = sanitize_session_key(uuid);
assert_eq!(key.len(), 36);
assert_eq!(key, uuid);
}
#[test]
fn sanitize_session_key_length_cap_adds_hash() {
let raw = "a".repeat(70);
let key = sanitize_session_key(&raw);
assert_eq!(key.len(), 81, "키: {key}");
let suffix = key.rsplit('-').next().expect("접미사 분리 실패");
assert_eq!(suffix.len(), 16, "해시 접미사는 16자(u64)여야 함: {suffix}");
}
#[test]
fn sanitize_session_key_collision_resolved() {
let a = sanitize_session_key("sess.A");
let b = sanitize_session_key("sess/A");
assert_ne!(a, b, "충돌 미해소: {a} == {b}");
assert!(a.starts_with("sessA-") && b.starts_with("sessA-"));
}
#[test]
fn sanitize_session_key_empty_falls_back() {
assert_eq!(sanitize_session_key(""), "default");
let traversal = sanitize_session_key("../../");
let hangul = sanitize_session_key("한글");
assert_ne!(traversal, "default", "traversal 입력이 default와 충돌");
assert_ne!(hangul, "default", "유니코드 입력이 default와 충돌");
assert_ne!(
traversal, hangul,
"서로 다른 입력이 같은 키를 공유: {traversal} == {hangul}"
);
for key in [&traversal, &hangul] {
assert!(key.starts_with("default-"), "default- 접두사 누락: {key}");
assert!(
!key.contains('.') && !key.contains('/') && !key.contains('\\'),
"위험 문자가 남음: {key}"
);
assert!(
key.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
"allowlist 위반: {key}"
);
}
}
#[test]
fn short_hash_width_is_u64() {
for raw in ["", "a", "../../etc", "긴문자열-입력-테스트-1234567890"] {
assert_eq!(short_hash(raw).len(), 16, "입력: {raw:?}");
}
}
#[test]
fn short_hash_is_deterministic() {
assert_eq!(short_hash("understatus"), short_hash("understatus"));
assert_eq!(short_hash("understatus"), "ac9557f018004fb7");
assert_eq!(short_hash(""), "cbf29ce484222325");
assert_eq!(short_hash("a"), "af63dc4c8601ec8c");
}
#[test]
fn session_cache_file_isolated_paths() {
let base = std::env::temp_dir();
let a = session_cache_file_in(&base, "AAA", "chain_output");
let b = session_cache_file_in(&base, "BBB", "chain_output");
assert_ne!(a, b);
assert_eq!(a, base.join("sessions").join("AAA").join("chain_output"));
assert_eq!(b, base.join("sessions").join("BBB").join("chain_output"));
}
#[test]
fn session_cache_file_resanitizes() {
let base = std::env::temp_dir();
let path = session_cache_file_in(&base, "../x", "net_counters");
let path_str = path.to_string_lossy();
assert!(!path_str.contains(".."), "traversal 누출: {path_str}");
assert!(
path.starts_with(base.join("sessions")),
"sessions/ 밖으로 나감: {path_str}"
);
}
#[test]
fn session_named_cache_roundtrip() {
let base =
std::env::temp_dir().join(format!("understatus-session-rt-{}", std::process::id()));
let path = session_cache_file_in(&base, "SESS-RT", "net_counters");
write_cache_entry(&path, 7_777, "100 200");
let (written_ms, payload) = read_cache_entry(&path).expect("세션 캐시 읽기 실패");
assert_eq!(written_ms, 7_777);
assert_eq!(payload, "100 200");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn battery_stays_global_not_session() {
if let Some(global) = cache_file("battery") {
let global_str = global.to_string_lossy();
assert!(
global_str.ends_with("understatus/battery"),
"battery 전역 경로 변경됨: {global_str}"
);
assert!(
!global_str.contains("sessions"),
"battery가 sessions/로 들어감: {global_str}"
);
}
}
}