use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, SystemTime};
pub(crate) const MAX_SAVED_OUTPUT_BYTES: usize = 16 * 1024 * 1024;
const MAX_LATER_CALLS: u64 = 32;
const MAX_AGE: Duration = Duration::from_secs(15 * 60);
const DIRECTORY_PREFIX: &str = "tau-shell-output-";
const FILE_NAME: &str = "output";
const LOCK_FILE_NAME: &str = "owner.lock";
struct SavedFile {
path: PathBuf,
created: SystemTime,
call: u64,
_owner_lock: fs::File,
}
#[derive(Default)]
struct Tracker {
call: u64,
files: Vec<SavedFile>,
scanned_leftovers: bool,
}
static TRACKER: OnceLock<Mutex<Tracker>> = OnceLock::new();
pub(crate) struct SavedOutput {
pub(crate) path: PathBuf,
pub(crate) incomplete: bool,
pub(crate) saved_bytes: usize,
}
pub(crate) enum SavedArtifact {
Available(SavedOutput),
Unavailable,
}
pub(crate) fn append_metadata(
entries: &mut Vec<(tau_proto::CborValue, tau_proto::CborValue)>,
rendered: &str,
) {
let mut end = rendered.len().min(MAX_SAVED_OUTPUT_BYTES);
while !rendered.is_char_boundary(end) {
end -= 1;
}
append_prefix_metadata(entries, &rendered[..end], end < rendered.len());
}
pub(crate) fn append_prefix_metadata(
entries: &mut Vec<(tau_proto::CborValue, tau_proto::CborValue)>,
prefix: &str,
incomplete: bool,
) {
use tau_proto::CborValue;
entries.push((
CborValue::Text("truncation_warning".to_owned()),
CborValue::Text(
"Fetching excessive output is inefficient; prefer narrower ranges or filters."
.to_owned(),
),
));
match save(prefix, incomplete) {
Ok(saved) => {
entries.push((
CborValue::Text(
if saved.incomplete {
"saved_output_path"
} else {
"full_output_path"
}
.to_owned(),
),
CborValue::Text(
saved
.path
.to_str()
.expect("spool accepts safe UTF-8 paths")
.to_owned(),
),
));
if saved.incomplete {
entries.push((
CborValue::Text("saved_output_truncated".to_owned()),
CborValue::Bool(true),
));
entries.push((
CborValue::Text("saved_output_bytes".to_owned()),
CborValue::Integer((saved.saved_bytes as i64).into()),
));
}
}
Err(_) => entries.push((
CborValue::Text("saved_output_unavailable".to_owned()),
CborValue::Bool(true),
)),
}
}
pub(crate) fn note_call() {
tracker().note_call();
}
pub(crate) fn save(output: &str, incomplete: bool) -> io::Result<SavedOutput> {
save_parts(&[output], incomplete)
}
pub(crate) fn save_parts(parts: &[&str], incomplete: bool) -> io::Result<SavedOutput> {
let saved_bytes = parts
.iter()
.fold(0usize, |total, part| total.saturating_add(part.len()));
if MAX_SAVED_OUTPUT_BYTES < saved_bytes {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"saved shell output exceeds hard cap",
));
}
let (directory, owner_lock) = create_private_directory()?;
let path = directory.join(FILE_NAME);
if path
.to_str()
.is_none_or(|path| path.chars().any(char::is_control))
{
let _ = remove_saved(&path);
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"temporary output path is not safe model metadata",
));
}
if let Err(error) = write_private_parts(&path, parts) {
let _ = fs::remove_file(&path);
let _ = fs::remove_file(directory.join(LOCK_FILE_NAME));
let _ = fs::remove_dir(&directory);
return Err(error);
}
tracker().track(path.clone(), owner_lock);
Ok(SavedOutput {
path,
incomplete,
saved_bytes,
})
}
fn write_private_parts(path: &Path, parts: &[&str]) -> io::Result<()> {
let mut file = write_private_file_handle(path)?;
for part in parts {
file.write_all(part.as_bytes())?;
}
Ok(())
}
pub(crate) fn shutdown() {
tracker().remove_all();
}
fn tracker() -> std::sync::MutexGuard<'static, Tracker> {
TRACKER
.get_or_init(|| Mutex::new(Tracker::default()))
.lock()
.unwrap_or_else(|error| error.into_inner())
}
impl Tracker {
fn note_call(&mut self) {
self.call = self.call.saturating_add(1);
if !self.scanned_leftovers {
cleanup_crash_leftovers();
self.scanned_leftovers = true;
}
let now = SystemTime::now();
let call = self.call;
self.files.retain(|saved| {
let expired_by_age = now
.duration_since(saved.created)
.is_ok_and(|age| MAX_AGE <= age);
let expired_by_calls = saved.call.saturating_add(MAX_LATER_CALLS) <= call;
if expired_by_age && expired_by_calls {
!remove_saved(&saved.path)
} else {
true
}
});
}
fn track(&mut self, path: PathBuf, owner_lock: fs::File) {
self.files.push(SavedFile {
path,
created: SystemTime::now(),
call: self.call,
_owner_lock: owner_lock,
});
}
fn remove_all(&mut self) {
self.files.retain(|saved| !remove_saved(&saved.path));
for _ in &self.files {
tracing::warn!("failed to remove ephemeral shell output");
}
}
}
fn cleanup_crash_leftovers() {
cleanup_crash_leftovers_in(&std::env::temp_dir(), SystemTime::now());
}
fn cleanup_crash_leftovers_in(temporary_directory: &Path, now: SystemTime) {
let Ok(entries) = fs::read_dir(temporary_directory) else {
return;
};
for entry in entries.flatten() {
if !entry
.file_name()
.to_string_lossy()
.starts_with(DIRECTORY_PREFIX)
{
continue;
}
let Ok(metadata) = fs::symlink_metadata(entry.path()) else {
continue;
};
if !metadata.file_type().is_dir() {
continue;
}
let old = entry
.metadata()
.and_then(|metadata| metadata.modified())
.ok()
.and_then(|modified| now.duration_since(modified).ok())
.is_some_and(|age| MAX_AGE <= age);
let lock_result = fs::OpenOptions::new()
.read(true)
.write(true)
.open(entry.path().join(LOCK_FILE_NAME));
let owner_dead = match lock_result {
Ok(file) => fs2::FileExt::try_lock_exclusive(&file).is_ok(),
Err(error) => error.kind() == io::ErrorKind::NotFound,
};
if old && owner_dead {
let _ = remove_saved(&entry.path().join(FILE_NAME));
}
}
}
fn create_private_directory() -> io::Result<(PathBuf, fs::File)> {
let directory = tempfile::Builder::new()
.prefix(DIRECTORY_PREFIX)
.tempdir_in(std::env::temp_dir())?;
set_mode(directory.path(), 0o300)?;
let owner_lock = write_private_file_handle(&directory.path().join(LOCK_FILE_NAME))?;
fs2::FileExt::lock_exclusive(&owner_lock)?;
let path = directory.keep();
Ok((path, owner_lock))
}
#[cfg(test)]
fn write_private_file(path: &Path, bytes: &[u8]) -> io::Result<()> {
let mut file = write_private_file_handle(path)?;
file.write_all(bytes)
}
fn write_private_file_handle(path: &Path) -> io::Result<fs::File> {
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
options.open(path)
}
fn remove_saved(path: &Path) -> bool {
let Some(directory) = path.parent() else {
return false;
};
let output_removed = remove_if_present(path);
let lock_removed = remove_if_present(&directory.join(LOCK_FILE_NAME));
let directory_removed = match fs::remove_dir(directory) {
Ok(()) => true,
Err(error) => error.kind() == io::ErrorKind::NotFound,
};
output_removed && lock_removed && directory_removed
}
fn remove_if_present(path: &Path) -> bool {
match fs::remove_file(path) {
Ok(()) => true,
Err(error) => error.kind() == io::ErrorKind::NotFound,
}
}
#[cfg(unix)]
fn set_mode(path: &Path, mode: u32) -> io::Result<()> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(mode))
}
#[cfg(not(unix))]
fn set_mode(_path: &Path, _mode: u32) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"private shell output artifacts are unsupported on this platform",
))
}
#[cfg(test)]
mod tests;