use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use crate::state::{self, StateRoots};
pub const RECORD_FILE: &str = "session.json";
const GC_MIN_AGE_SECS: u64 = 60;
fn default_backend() -> String {
"overlayfs".to_string()
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SessionRecord {
pub id: String,
pub target: PathBuf,
#[serde(default = "default_backend")]
pub backend: String,
#[serde(default)]
pub upper: Option<PathBuf>,
#[serde(default)]
pub work: Option<PathBuf>,
#[serde(default)]
pub snapshot: Option<PathBuf>,
#[serde(default)]
pub parent_dev: Option<u64>,
#[serde(default)]
pub parent_ino: Option<u64>,
pub command: String,
pub created_unix: u64,
pub exit_code: Option<i32>,
}
pub struct Session {
pub root: PathBuf,
pub dir: PathBuf,
pub record: SessionRecord,
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn new_session_id() -> String {
format!("{}-{}", now_unix(), std::process::id())
}
#[allow(dead_code)] pub enum BackendKind {
Overlayfs,
Apfs,
}
pub fn create(root: &Path, target: &Path, command: &str, kind: BackendKind) -> Result<Session> {
let id = new_session_id();
let dir = state::sessions_dir(root).join(&id);
std::fs::create_dir_all(&dir)
.with_context(|| format!("cannot create session directory {}", dir.display()))?;
use std::os::unix::fs::MetadataExt;
let parent = target
.parent()
.with_context(|| format!("target {} has no parent directory", target.display()))?;
let pmeta = parent
.symlink_metadata()
.with_context(|| format!("cannot stat parent {}", parent.display()))?;
let mut record = SessionRecord {
id,
target: target.to_path_buf(),
backend: String::new(),
upper: None,
work: None,
snapshot: None,
parent_dev: Some(pmeta.dev()),
parent_ino: Some(pmeta.ino()),
command: command.to_string(),
created_unix: now_unix(),
exit_code: None,
};
match kind {
BackendKind::Overlayfs => {
let upper = dir.join("upper");
let work = dir.join("work");
std::fs::create_dir_all(&upper)?;
std::fs::create_dir_all(&work)?;
record.backend = "overlayfs".into();
record.upper = Some(upper);
record.work = Some(work);
}
BackendKind::Apfs => {
record.backend = "apfs".into();
record.snapshot = Some(dir.join("snapshot"));
}
}
save(&dir, &record)?;
Ok(Session {
root: root.to_path_buf(),
dir,
record,
})
}
pub fn save(dir: &Path, record: &SessionRecord) -> Result<()> {
let json = serde_json::to_string_pretty(record)?;
std::fs::write(dir.join(RECORD_FILE), json)
.with_context(|| format!("cannot write session record in {}", dir.display()))?;
Ok(())
}
fn load(dir: &Path) -> Result<SessionRecord> {
let raw = std::fs::read_to_string(dir.join(RECORD_FILE))?;
Ok(serde_json::from_str(&raw)?)
}
pub fn find_for_target(roots: &StateRoots, target: &Path) -> Result<Option<Session>> {
for root in roots.mounted() {
let sessions = state::sessions_dir(&root);
if !sessions.is_dir() {
continue;
}
for entry in std::fs::read_dir(&sessions)? {
let dir = entry?.path();
if !dir.is_dir() {
continue;
}
if let Ok(record) = load(&dir) {
if record.target == target {
return Ok(Some(Session { root, dir, record }));
}
}
}
}
Ok(None)
}
pub fn move_to_trash(roots: &StateRoots, root: &Path, session_dir: &Path) -> Result<PathBuf> {
roots.ensure_contains(session_dir)?;
let trash = state::trash_dir(root);
std::fs::create_dir_all(&trash)?;
let name = session_dir
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "session".into());
let dest = trash.join(format!("{}.{}.{}", name, now_unix(), std::process::id()));
std::fs::rename(session_dir, &dest)
.with_context(|| format!("cannot move session into trash at {}", dest.display()))?;
Ok(dest)
}
pub fn gc_sweep(roots: &StateRoots) -> Result<()> {
for root in roots.mounted() {
let trash = state::trash_dir(&root);
if trash.is_dir() {
for entry in std::fs::read_dir(&trash)? {
let path = entry?.path();
if roots.ensure_contains(&path).is_ok() {
let _ = remove_all(&path);
}
}
}
let sessions = state::sessions_dir(&root);
if sessions.is_dir() {
for entry in std::fs::read_dir(&sessions)? {
let dir = entry?.path();
if !dir.is_dir() || load(&dir).is_ok() {
continue;
}
let age = std::fs::metadata(&dir)
.and_then(|m| m.modified())
.ok()
.and_then(|m| m.elapsed().ok())
.map(|e| e.as_secs())
.unwrap_or(0);
if age >= GC_MIN_AGE_SECS {
let _ = move_to_trash(roots, &root, &dir);
}
}
}
}
Ok(())
}
fn remove_all(path: &Path) -> std::io::Result<()> {
if path.is_dir() {
std::fs::remove_dir_all(path)
} else {
std::fs::remove_file(path)
}
}
pub fn spawn_background_gc() {
if let Ok(exe) = std::env::current_exe() {
let _ = std::process::Command::new(exe)
.arg("__gc")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
}
}
pub fn ensure_no_pending(roots: &StateRoots, target: &Path) -> Result<()> {
if let Some(existing) = find_for_target(roots, target)? {
bail!(
"a sandbox is already pending for {} (from `oops run \"{}\"`).\n\
Inspect it with `oops diff`, then `oops undo` or `oops commit` first.",
target.display(),
existing.record.command
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn roots(tmp: &tempfile::TempDir) -> StateRoots {
let primary = tmp.path().join("state");
std::fs::create_dir_all(&primary).unwrap();
StateRoots {
primary,
registered: Vec::new(),
}
}
#[test]
fn create_find_trash_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let roots = roots(&tmp);
let target = tmp.path().join("proj");
std::fs::create_dir(&target).unwrap();
let s = create(&roots.primary, &target, "echo hi", BackendKind::Overlayfs).unwrap();
assert!(s.record.exit_code.is_none());
assert_eq!(s.record.backend, "overlayfs");
assert!(s.record.parent_dev.is_some() && s.record.parent_ino.is_some());
let found = find_for_target(&roots, &target).unwrap().unwrap();
assert_eq!(found.record.command, "echo hi");
assert!(ensure_no_pending(&roots, &target).is_err());
move_to_trash(&roots, &found.root, &found.dir).unwrap();
assert!(find_for_target(&roots, &target).unwrap().is_none());
gc_sweep(&roots).unwrap();
assert_eq!(
std::fs::read_dir(roots.primary.join("trash"))
.unwrap()
.count(),
0
);
}
#[test]
fn old_records_without_backend_field_load_as_overlayfs() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("sess");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(RECORD_FILE),
r#"{"id":"x","target":"/t","upper":"/s/upper","work":"/s/work",
"command":"true","created_unix":1,"exit_code":0}"#,
)
.unwrap();
let rec = load(&dir).unwrap();
assert_eq!(rec.backend, "overlayfs");
assert_eq!(rec.upper.as_deref(), Some(Path::new("/s/upper")));
assert!(rec.snapshot.is_none());
}
#[test]
fn gc_sweeps_all_mounted_roots_and_spares_valid_sessions() {
let tmp = tempfile::tempdir().unwrap();
let mut roots = roots(&tmp);
let volume = tmp.path().join("vol/.oops/state");
std::fs::create_dir_all(state::trash_dir(&volume)).unwrap();
std::fs::write(state::trash_dir(&volume).join("junk"), "x").unwrap();
roots.registered.push(volume.clone());
let target = tmp.path().join("proj");
std::fs::create_dir(&target).unwrap();
let valid = create(&roots.primary, &target, "true", BackendKind::Apfs).unwrap();
let fresh_orphan = roots.primary.join("sessions/fresh-orphan");
std::fs::create_dir_all(&fresh_orphan).unwrap();
gc_sweep(&roots).unwrap();
assert!(valid.dir.is_dir(), "valid session must survive gc");
assert!(
fresh_orphan.is_dir(),
"fresh orphan must not be quarantined (race guard)"
);
assert_eq!(
std::fs::read_dir(state::trash_dir(&volume))
.unwrap()
.count(),
0,
"registered volume root trash must be swept"
);
}
}