1use crate::constants::paths::{LEGACY_PROMPT_PREFIX, RALPH_TEMP_DIR_NAME};
24
25pub use crate::constants::paths::RALPH_TEMP_PREFIX;
27use anyhow::{Context, Result};
28use std::fs;
29use std::io::Write;
30use std::path::{Path, PathBuf};
31use std::time::{Duration, SystemTime};
32
33pub fn expand_tilde(path: &Path) -> PathBuf {
46 let raw = path.to_string_lossy();
47
48 let home = std::env::var("HOME")
49 .ok()
50 .map(|v| v.trim().to_string())
51 .filter(|v| !v.is_empty());
52
53 let Some(home) = home else {
54 log::debug!(
55 "HOME environment variable not set; skipping tilde expansion for path: {}",
56 raw
57 );
58 return path.to_path_buf();
59 };
60
61 if raw == "~" {
62 return PathBuf::from(home);
63 }
64
65 if let Some(rest) = raw.strip_prefix("~/") {
66 let rest = rest.trim_start_matches(&['/', '\\'][..]);
68 return PathBuf::from(home).join(rest);
69 }
70
71 path.to_path_buf()
72}
73
74pub fn ralph_temp_root() -> PathBuf {
75 std::env::temp_dir().join(RALPH_TEMP_DIR_NAME)
76}
77
78pub fn cleanup_stale_temp_entries(
79 base: &Path,
80 prefixes: &[&str],
81 retention: Duration,
82) -> Result<usize> {
83 if !base.exists() {
84 return Ok(0);
85 }
86
87 let now = SystemTime::now();
88 let mut removed = 0usize;
89
90 for entry in fs::read_dir(base).with_context(|| format!("read temp dir {}", base.display()))? {
91 let entry = entry.with_context(|| format!("read temp dir entry in {}", base.display()))?;
92 let path = entry.path();
93 let name = entry.file_name();
94 let name = name.to_string_lossy();
95
96 if !prefixes.iter().any(|prefix| name.starts_with(prefix)) {
97 continue;
98 }
99
100 let metadata = match entry.metadata() {
101 Ok(metadata) => metadata,
102 Err(err) => {
103 log::warn!(
104 "unable to read temp metadata for {}: {}",
105 path.display(),
106 err
107 );
108 continue;
109 }
110 };
111
112 let modified = match metadata.modified() {
113 Ok(time) => time,
114 Err(err) => {
115 log::warn!(
116 "unable to read temp modified time for {}: {}",
117 path.display(),
118 err
119 );
120 continue;
121 }
122 };
123
124 let age = match now.duration_since(modified) {
125 Ok(age) => age,
126 Err(_) => continue,
127 };
128
129 if age < retention {
130 continue;
131 }
132
133 if metadata.is_dir() {
134 if fs::remove_dir_all(&path).is_ok() {
135 removed += 1;
136 } else {
137 log::warn!("failed to remove temp dir {}", path.display());
138 }
139 } else if fs::remove_file(&path).is_ok() {
140 removed += 1;
141 } else {
142 log::warn!("failed to remove temp file {}", path.display());
143 }
144 }
145
146 Ok(removed)
147}
148
149pub fn cleanup_stale_temp_dirs(base: &Path, retention: Duration) -> Result<usize> {
150 cleanup_stale_temp_entries(base, &[RALPH_TEMP_PREFIX], retention)
151}
152
153pub fn cleanup_default_temp_dirs(retention: Duration) -> Result<usize> {
154 let mut removed = 0usize;
155 removed += cleanup_stale_temp_dirs(&ralph_temp_root(), retention)?;
156 removed +=
157 cleanup_stale_temp_entries(&std::env::temp_dir(), &[LEGACY_PROMPT_PREFIX], retention)?;
158 Ok(removed)
159}
160
161pub fn create_ralph_temp_dir(label: &str) -> Result<tempfile::TempDir> {
162 let base = ralph_temp_root();
163 fs::create_dir_all(&base).with_context(|| format!("create temp dir {}", base.display()))?;
164 let prefix = format!(
165 "{prefix}{label}_",
166 prefix = RALPH_TEMP_PREFIX,
167 label = label.trim()
168 );
169 let dir = tempfile::Builder::new()
170 .prefix(&prefix)
171 .tempdir_in(&base)
172 .with_context(|| format!("create temp dir in {}", base.display()))?;
173 Ok(dir)
174}
175
176pub fn create_ralph_temp_file(label: &str) -> Result<tempfile::NamedTempFile> {
179 let base = ralph_temp_root();
180 fs::create_dir_all(&base).with_context(|| format!("create temp dir {}", base.display()))?;
181 let prefix = format!(
182 "{prefix}{label}_",
183 prefix = RALPH_TEMP_PREFIX,
184 label = label.trim()
185 );
186 tempfile::Builder::new()
187 .prefix(&prefix)
188 .suffix(".tmp")
189 .tempfile_in(&base)
190 .with_context(|| format!("create temp file in {}", base.display()))
191}
192
193use crate::constants::paths::ENV_RAW_DUMP;
194
195pub fn safeguard_text_dump_redacted(label: &str, content: &str) -> Result<PathBuf> {
202 use crate::redaction::redact_text;
203 let redacted_content = redact_text(content);
204 safeguard_text_dump_internal(label, &redacted_content, true)
205}
206
207pub fn safeguard_text_dump(label: &str, content: &str, is_debug_mode: bool) -> Result<PathBuf> {
217 let raw_dump_enabled = std::env::var(ENV_RAW_DUMP)
218 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
219 .unwrap_or(false);
220
221 if !raw_dump_enabled && !is_debug_mode {
222 anyhow::bail!(
223 "Raw safeguard dumps require explicit opt-in. \
224 Set {}=1 or use --debug mode. \
225 Consider using safeguard_text_dump_redacted() for safe dumping.",
226 ENV_RAW_DUMP
227 );
228 }
229
230 if raw_dump_enabled {
231 log::warn!(
232 "SECURITY: Writing raw safeguard dump ({}=1). Secrets may be written to disk.",
233 ENV_RAW_DUMP
234 );
235 }
236
237 safeguard_text_dump_internal(label, content, false)
238}
239
240fn safeguard_text_dump_internal(label: &str, content: &str, _is_redacted: bool) -> Result<PathBuf> {
241 let temp_dir = create_ralph_temp_dir(label)?;
242 let output_path = temp_dir.path().join("output.txt");
243 fs::write(&output_path, content)
244 .with_context(|| format!("write safeguard dump to {}", output_path.display()))?;
245
246 let dir_path = temp_dir.keep();
248 Ok(dir_path.join("output.txt"))
249}
250
251pub fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
252 log::debug!("atomic write: {}", path.display());
253 let dir = path
254 .parent()
255 .context("atomic write requires a parent directory")?;
256 fs::create_dir_all(dir).with_context(|| format!("create directory {}", dir.display()))?;
257
258 let mut tmp = tempfile::NamedTempFile::new_in(dir)
259 .with_context(|| format!("create temp file in {}", dir.display()))?;
260 tmp.write_all(contents).context("write temp file")?;
261 tmp.flush().context("flush temp file")?;
262 tmp.as_file().sync_all().context("sync temp file")?;
263
264 match tmp.persist(path) {
265 Ok(_) => {}
266 Err(err) => {
267 let _temp_file = err.file;
271 drop(_temp_file);
272 return Err(err.error).with_context(|| format!("persist {}", path.display()));
273 }
274 }
275
276 sync_dir_best_effort(dir);
277 Ok(())
278}
279
280pub(crate) fn sync_dir_best_effort(dir: &Path) {
281 #[cfg(unix)]
282 {
283 log::debug!("syncing directory: {}", dir.display());
284 if let Ok(file) = fs::File::open(dir) {
285 let _ = file.sync_all();
286 }
287 }
288
289 #[cfg(not(unix))]
290 {
291 let _ = dir;
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use std::sync::{Mutex, OnceLock};
299
300 fn env_lock() -> &'static Mutex<()> {
301 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
302 LOCK.get_or_init(|| Mutex::new(()))
303 }
304
305 #[test]
306 fn safeguard_text_dump_redacted_masks_secrets() {
307 let content = "API_KEY=sk-abc123xyz789\nAuthorization: Bearer secret_token_12345";
308 let path = safeguard_text_dump_redacted("test_redacted", content).unwrap();
309
310 let written = fs::read_to_string(&path).unwrap();
311
312 assert!(
313 !written.contains("sk-abc123xyz789"),
314 "API key should be redacted"
315 );
316 assert!(
317 !written.contains("secret_token_12345"),
318 "Bearer token should be redacted"
319 );
320 assert!(
321 written.contains("[REDACTED]"),
322 "Should contain redaction marker"
323 );
324
325 let _ = fs::remove_file(&path);
327 if let Some(parent) = path.parent() {
328 let _ = fs::remove_dir(parent);
329 }
330 }
331
332 #[test]
333 fn safeguard_text_dump_requires_opt_in_without_debug() {
334 let _guard = env_lock().lock().expect("env lock");
335
336 unsafe { std::env::remove_var(ENV_RAW_DUMP) }
338
339 let content = "sensitive data";
340 let result = safeguard_text_dump("test_raw", content, false);
341
342 assert!(result.is_err(), "Raw dump should fail without opt-in");
343 let err_msg = result.unwrap_err().to_string();
344 assert!(
345 err_msg.contains("RALPH_RAW_DUMP"),
346 "Error should mention env var"
347 );
348 }
349
350 #[test]
351 fn safeguard_text_dump_allows_raw_with_env_var() {
352 let _guard = env_lock().lock().expect("env lock");
353
354 unsafe { std::env::set_var(ENV_RAW_DUMP, "1") };
355
356 let content = "raw secret data";
357 let path = safeguard_text_dump("test_raw_env", content, false).unwrap();
358
359 let written = fs::read_to_string(&path).unwrap();
360 assert_eq!(written, content, "Raw content should be written unchanged");
361
362 unsafe { std::env::remove_var(ENV_RAW_DUMP) }
364 let _ = fs::remove_file(&path);
365 if let Some(parent) = path.parent() {
366 let _ = fs::remove_dir(parent);
367 }
368 }
369
370 #[test]
371 fn safeguard_text_dump_allows_raw_with_debug_mode() {
372 let _guard = env_lock().lock().expect("env lock");
373
374 unsafe { std::env::remove_var(ENV_RAW_DUMP) }
376
377 let content = "debug mode secret";
378 let path = safeguard_text_dump("test_raw_debug", content, true).unwrap();
379
380 let written = fs::read_to_string(&path).unwrap();
381 assert_eq!(
382 written, content,
383 "Raw content should be written in debug mode"
384 );
385
386 let _ = fs::remove_file(&path);
388 if let Some(parent) = path.parent() {
389 let _ = fs::remove_dir(parent);
390 }
391 }
392
393 #[test]
394 fn safeguard_text_dump_preserves_non_sensitive_content() {
395 let content = "This is normal log output without secrets";
396 let path = safeguard_text_dump_redacted("test_normal", content).unwrap();
397
398 let written = fs::read_to_string(&path).unwrap();
399 assert_eq!(
400 written, content,
401 "Non-sensitive content should be preserved"
402 );
403
404 let _ = fs::remove_file(&path);
406 if let Some(parent) = path.parent() {
407 let _ = fs::remove_dir(parent);
408 }
409 }
410
411 #[test]
412 fn safeguard_text_dump_redacts_aws_keys() {
413 let content = "AWS Access Key: AKIAIOSFODNN7EXAMPLE";
414 let path = safeguard_text_dump_redacted("test_aws", content).unwrap();
415
416 let written = fs::read_to_string(&path).unwrap();
417 assert!(
418 !written.contains("AKIAIOSFODNN7EXAMPLE"),
419 "AWS key should be redacted"
420 );
421 assert!(
422 written.contains("[REDACTED]"),
423 "Should contain redaction marker"
424 );
425
426 let _ = fs::remove_file(&path);
428 if let Some(parent) = path.parent() {
429 let _ = fs::remove_dir(parent);
430 }
431 }
432
433 #[test]
434 fn safeguard_text_dump_redacts_ssh_keys() {
435 let content = "SSH Key:\n-----BEGIN OPENSSH PRIVATE KEY-----\nabc123\n-----END OPENSSH PRIVATE KEY-----";
436 let path = safeguard_text_dump_redacted("test_ssh", content).unwrap();
437
438 let written = fs::read_to_string(&path).unwrap();
439 assert!(
440 !written.contains("abc123"),
441 "SSH key content should be redacted"
442 );
443 assert!(
444 written.contains("[REDACTED]"),
445 "Should contain redaction marker"
446 );
447
448 let _ = fs::remove_file(&path);
450 if let Some(parent) = path.parent() {
451 let _ = fs::remove_dir(parent);
452 }
453 }
454
455 #[test]
456 #[cfg(unix)]
457 fn write_atomic_cleans_up_temp_file_on_persist_failure() {
458 use std::os::unix::fs::PermissionsExt;
459
460 let temp_dir = tempfile::TempDir::new().unwrap();
461 let target_dir = temp_dir.path().join("readonly");
462 fs::create_dir(&target_dir).unwrap();
463
464 let existing_file = target_dir.join("existing.txt");
467 fs::write(&existing_file, "existing content").unwrap();
468
469 let mut perms = fs::metadata(&target_dir).unwrap().permissions();
471 perms.set_mode(0o555); fs::set_permissions(&target_dir, perms).unwrap();
473
474 let target_file = target_dir.join("test.txt");
476 let result = write_atomic(&target_file, b"test content");
477
478 assert!(
480 result.is_err(),
481 "write_atomic should fail in read-only directory"
482 );
483
484 let entries: Vec<_> = fs::read_dir(&target_dir)
486 .unwrap()
487 .filter_map(|e| e.ok())
488 .filter(|e| {
489 let name = e.file_name().to_string_lossy().to_string();
490 name.starts_with(".") || name.starts_with("tmp") || name.starts_with("ralph")
491 })
492 .collect();
493 assert!(
494 entries.is_empty(),
495 "Temp files should be cleaned up, found: {:?}",
496 entries.iter().map(|e| e.file_name()).collect::<Vec<_>>()
497 );
498
499 let mut perms = fs::metadata(&target_dir).unwrap().permissions();
501 perms.set_mode(0o755);
502 fs::set_permissions(&target_dir, perms).unwrap();
503 }
504
505 #[test]
506 fn create_ralph_temp_file_uses_ralph_prefix() {
507 let temp = create_ralph_temp_file("test").unwrap();
508 let name = temp.path().file_name().unwrap().to_string_lossy();
509 assert!(
510 name.starts_with("ralph_test_"),
511 "temp file should have ralph prefix, got: {}",
512 name
513 );
514 let parent = temp.path().parent().unwrap();
515 assert!(
516 parent.ends_with("ralph"),
517 "temp file should be in ralph temp directory, got: {}",
518 parent.display()
519 );
520 }
521
522 #[test]
523 fn create_ralph_temp_file_is_cleaned_on_drop() {
524 let path;
525 {
526 let temp = create_ralph_temp_file("test").unwrap();
527 path = temp.path().to_path_buf();
528 assert!(path.exists(), "temp file should exist while held");
529 }
530 assert!(!path.exists(), "temp file should be removed on drop");
532 }
533
534 #[test]
535 fn create_ralph_temp_file_accepts_content() {
536 use std::io::Write;
537
538 let mut temp = create_ralph_temp_file("test").unwrap();
539 temp.write_all(b"test content").unwrap();
540 temp.flush().unwrap();
541
542 let content = fs::read_to_string(temp.path()).unwrap();
543 assert_eq!(content, "test content");
544 }
545}