use anyhow::Result;
use lazy_static::lazy_static;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
static COUNTER: AtomicU64 = AtomicU64::new(0);
struct Undo {
original: PathBuf,
backup: Option<PathBuf>,
existed: bool,
is_dir: bool,
meta: Option<MetaSnap>,
}
struct MetaSnap {
permissions: std::fs::Permissions,
#[cfg(unix)]
uid: u32,
#[cfg(unix)]
gid: u32,
modified: Option<std::time::SystemTime>,
accessed: Option<std::time::SystemTime>,
}
pub(crate) fn open_for_times(path: &Path) -> std::io::Result<std::fs::File> {
if std::fs::metadata(path)?.is_dir() {
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
const FILE_WRITE_ATTRIBUTES: u32 = 0x100;
const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
return std::fs::OpenOptions::new()
.access_mode(FILE_WRITE_ATTRIBUTES)
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
.open(path);
}
#[cfg(not(windows))]
return std::fs::File::open(path);
}
std::fs::File::options().write(true).open(path)
}
#[allow(clippy::permissions_set_readonly_false)]
fn set_times_of(
path: &Path,
accessed: Option<std::time::SystemTime>,
modified: Option<std::time::SystemTime>,
) -> std::io::Result<()> {
let mut times = std::fs::FileTimes::new();
if let Some(t) = accessed {
times = times.set_accessed(t);
}
if let Some(t) = modified {
times = times.set_modified(t);
}
let open = || open_for_times(path);
let file = match open() {
Ok(f) => f,
Err(e) => {
let mut perms = std::fs::metadata(path)?.permissions();
if !perms.readonly() {
return Err(e);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
perms.set_mode(perms.mode() | 0o200);
}
#[cfg(not(unix))]
perms.set_readonly(false);
std::fs::set_permissions(path, perms)?;
open()?
}
};
file.set_times(times)
}
fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let ft = entry.file_type()?;
let from = entry.path();
let to = dst.join(entry.file_name());
if ft.is_dir() {
copy_tree(&from, &to)?;
} else if ft.is_file() {
copy_file(&from, &to)?;
}
}
Ok(())
}
fn copy_file(from: &Path, to: &Path) -> std::io::Result<()> {
let mut src = std::fs::File::open(from)?;
#[cfg(unix)]
let mut dst = {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(to)?
};
#[cfg(not(unix))]
let mut dst = std::fs::File::create(to)?;
std::io::copy(&mut src, &mut dst)?;
drop(dst);
if let Ok(md) = std::fs::metadata(from) {
let _ = std::fs::set_permissions(to, md.permissions());
}
Ok(())
}
fn rollback_incomplete(
builtin: &str,
failures: &[String],
journal: Option<&Path>,
) -> anyhow::Error {
let kept = journal
.map(|d| format!("; the journal is kept at `{}`", d.display()))
.unwrap_or_default();
anyhow::Error::new(crate::safety::SafetyError {
code: crate::safety::ErrorCode::Io,
message: format!(
"{builtin}: {} change(s) could not be restored: {}{kept}",
failures.len(),
failures.join("; ")
),
builtin: builtin.to_string(),
hint: "check those paths by hand; the rest of the transaction was undone".to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
})
}
struct Frame {
id: String,
start: usize,
seen: HashSet<PathBuf>,
seen_meta: HashSet<PathBuf>,
savepoints: Vec<(String, usize)>,
}
struct TxState {
dir: PathBuf,
undos: Vec<Undo>,
n: u64,
frames: Vec<Frame>,
}
fn restore_one(u: &Undo) -> std::result::Result<bool, String> {
let at = u.original.display();
if let Some(m) = &u.meta {
#[cfg(unix)]
std::os::unix::fs::chown(&u.original, Some(m.uid), Some(m.gid))
.map_err(|e| format!("`{at}` owner: {e}"))?;
let times = if m.modified.is_some() || m.accessed.is_some() {
set_times_of(&u.original, m.accessed, m.modified)
.map_err(|e| format!("`{at}` times: {e}"))
} else {
Ok(())
};
std::fs::set_permissions(&u.original, m.permissions.clone())
.map_err(|e| format!("`{at}` permissions: {e}"))?;
times?;
return Ok(true);
}
if u.existed {
let Some(b) = &u.backup else {
return Err(format!(
"`{at}`: no backup could be taken before it changed"
));
};
if u.is_dir {
let cleared = match std::fs::remove_dir_all(&u.original) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
Err(format!("`{at}` could not be cleared before restoring: {e}"))
}
_ => Ok(()),
};
copy_tree(b, &u.original).map_err(|e| format!("`{at}`: {e}"))?;
cleared?;
} else {
if let Some(parent) = u.original.parent() {
let _ = std::fs::create_dir_all(parent);
}
copy_file(b, &u.original).map_err(|e| format!("`{at}`: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = |p: &Path| -> Result<u32, String> {
let m = std::fs::metadata(p).map_err(|e| format!("`{at}`: {e}"))?;
Ok(m.permissions().mode() & 0o7777)
};
let want = mode(b.as_path())?;
let got = mode(u.original.as_path())?;
if got != want {
return Err(format!(
"`{at}` was restored, but its mode could not be: {got:o} instead of {want:o}"
));
}
}
}
Ok(true)
} else if let Some(removed) = remove_created_symlink(&u.original) {
if removed {
Ok(true)
} else {
Err(format!("`{at}`: the link it created could not be removed"))
}
} else if u.original.is_file() {
std::fs::remove_file(&u.original).map_err(|e| format!("`{at}`: {e}"))?;
Ok(true)
} else if u.original.is_dir() {
std::fs::remove_dir_all(&u.original).map_err(|e| format!("`{at}`: {e}"))?;
Ok(true)
} else {
Ok(false)
}
}
fn remove_created_symlink(path: &Path) -> Option<bool> {
let md = std::fs::symlink_metadata(path).ok()?;
if !md.file_type().is_symlink() {
return None;
}
#[cfg(windows)]
let result = {
use std::os::windows::fs::FileTypeExt;
if md.file_type().is_symlink_dir() {
std::fs::remove_dir(path)
} else {
std::fs::remove_file(path)
}
};
#[cfg(not(windows))]
let result = std::fs::remove_file(path);
Some(result.is_ok())
}
lazy_static! {
static ref TX: Mutex<Option<TxState>> = Mutex::new(None);
}
fn absolutize(path: &str) -> PathBuf {
let p = Path::new(path);
if p.is_absolute() {
p.to_path_buf()
} else {
crate::safety::workspace_root().join(p)
}
}
fn new_id() -> String {
format!(
"tx_{}_{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::SeqCst)
)
}
pub const IRREVERSIBLE: &[&str] = &[
"a2a_broadcast",
"a2a_receive",
"a2a_register",
"a2a_send",
"a2a_unregister",
"act",
"add_entity",
"add_node",
"add_relation",
"age_decrypt",
"age_encrypt",
"age_keygen",
"agent",
"agent_with_mcp",
"ai",
"ai_add_route",
"ai_convert_model",
"ai_local_generate",
"ai_reset_usage",
"ai_set_default",
"ai_set_load_balancing",
"ansible_galaxy",
"ansible_playbook",
"ansible_vault",
"asdf_install",
"black",
"buildah_build",
"bun_install",
"bun_run",
"bzip2_decompress",
"cargo_build_cmd",
"cargo_run",
"cargo_test_cmd",
"chgrp",
"clipboard_clear",
"clipboard_set",
"clipboard_set_image",
"cloud_config",
"cloud_instance_destroy",
"cluster",
"cluster_remove_node",
"cmake_build",
"cmake_configure",
"config_init",
"config_set",
"create_role",
"db_sqlite_open",
"delete_role",
"deno_run",
"deno_task",
"diag_all",
"diag_check",
"diag_errors",
"diag_fix",
"diag_lint",
"diag_warnings",
"direnv_allow",
"docker_build",
"docker_compose_down",
"docker_compose_up",
"docker_cp",
"docker_exec",
"docker_pull",
"docker_push",
"docker_rm",
"docker_run",
"docker_stop",
"docker_tag",
"docs_generate",
"exit",
"finetune_cancel",
"gdb",
"gh_issue",
"gh_pr",
"git_add",
"git_checkout",
"git_cherry_pick",
"git_clean",
"git_commit",
"git_fetch",
"git_merge",
"git_pull",
"git_push",
"git_rebase",
"git_reset",
"git_stash",
"git_stash_pop",
"glab_issue",
"glab_mr",
"go_build",
"go_run",
"go_test",
"gpg_decrypt",
"gpg_encrypt",
"grant_permission",
"gui_close_window",
"gui_focus_window",
"gui_key_combo",
"gui_key_press",
"gui_maximize_window",
"gui_minimize_window",
"gui_mouse_click",
"gui_mouse_double_click",
"gui_mouse_drag",
"gui_mouse_move",
"gui_mouse_scroll",
"gui_move_window",
"gui_notify",
"gui_resize_window",
"gui_screenshot",
"gui_type_text",
"handle_drop",
"helm_install",
"helm_uninstall",
"helm_upgrade",
"hyperfine",
"index_docs",
"init_sso",
"job_cancel",
"journal_clear",
"just",
"k8s_apply",
"k8s_context",
"k8s_delete",
"k8s_exec",
"k8s_rollout_restart",
"k8s_scale",
"kill",
"lazygit",
"lldb",
"logout_sso",
"logrotate",
"ltrace",
"mage",
"make_run",
"marketplace_install",
"marketplace_publish",
"marketplace_rate",
"marketplace_uninstall",
"mcp_call",
"mise_use",
"monitor_alert_create",
"monitor_alert_remove",
"monitor_perf_record",
"monitor_perf_stat",
"nanda_abort",
"nanda_commit",
"nanda_propose",
"nanda_vote",
"ninja_build",
"node_run",
"npm_install",
"npm_run",
"openssl_genrsa",
"pipx_install",
"pkill",
"platform_check_reqs",
"platform_db_delete",
"platform_db_import",
"platform_db_init",
"platform_db_store",
"platform_require",
"platform_satisfies",
"plugin_disable",
"plugin_enable",
"plugin_load",
"plugin_unload",
"pnpm_install",
"pnpm_run",
"podman_build",
"podman_exec",
"podman_pull",
"podman_rm",
"podman_run",
"podman_stop",
"poetry_install",
"poetry_run",
"pre_commit_install",
"pre_commit_run",
"prettier",
"proc_resume",
"proc_set_priority",
"proc_spawn",
"proc_suspend",
"pytest",
"rag_clear",
"rbac_grant",
"rbac_login",
"rbac_logout",
"rbac_principal",
"rbac_register",
"recursive_agent",
"refactor_remove_unused",
"remove_node",
"repl_disconnect",
"revoke_permission",
"rewind",
"rlm_spawn",
"role_create",
"role_grant",
"role_revoke",
"rsync",
"ruff_format",
"rustup_update",
"scp_download",
"scp_upload",
"screen_attach",
"screen_new",
"sess_eval",
"session_checkpoint",
"session_end",
"session_redo",
"session_restore",
"session_rollback",
"session_undo",
"sh",
"skopeo_copy",
"ssh_copy_id",
"ssh_exec",
"ssh_keygen",
"ssh_tunnel",
"sso_logout",
"strace",
"svc_disable",
"svc_enable",
"svc_restart",
"svc_start",
"svc_stop",
"swarm",
"syntax_add",
"tar_create",
"task_run",
"terraform_apply",
"terraform_destroy",
"terraform_init",
"terraform_workspace",
"test_bench",
"test_list",
"test_run",
"test_run_file",
"test_run_function",
"test_watch",
"timeout",
"tmux_attach",
"tmux_kill",
"tmux_new",
"tmux_send",
"tool_exec",
"undo",
"uv_pip",
"uv_run",
"uv_venv",
"valgrind",
"valgrind_memcheck",
"vault_convert",
"web_browser_open",
"web_form_submit",
"web_graphql",
"web_json_post",
"web_open_url",
"web_post",
"web_rest_api",
"web_upload_file",
"web_websocket",
"workspace_create",
"workspace_join",
"workspace_leave",
"workspace_share_agent",
"xargs",
"xz_compress",
"xz_decompress",
"yarn_install",
"yarn_run",
"zoxide_add",
"zstd_compress",
"zstd_decompress",
];
pub fn is_active() -> bool {
TX.lock().map(|g| g.is_some()).unwrap_or(false)
}
pub fn depth() -> usize {
TX.lock()
.ok()
.and_then(|g| g.as_ref().map(|t| t.frames.len()))
.unwrap_or(0)
}
pub fn status() -> Option<(String, usize)> {
TX.lock().ok().and_then(|g| {
g.as_ref().map(|t| {
let id = t.frames.last().map(|f| f.id.clone()).unwrap_or_default();
(id, t.undos.len())
})
})
}
fn discard_journal(dir: &Path) {
if let Err(e) = std::fs::remove_dir_all(dir) {
if e.kind() != std::io::ErrorKind::NotFound {
tracing::warn!(
target: "journal",
"transaction journal {} could not be removed and holds copies of changed files: {e}",
dir.display()
);
}
}
}
pub fn begin() -> Result<String> {
let mut g = TX
.lock()
.map_err(|_| crate::safety::lock_poisoned("tx_begin", "the transaction state"))?;
let id = new_id();
match g.as_mut() {
Some(tx) => {
let start = tx.undos.len();
tx.frames.push(Frame {
id: id.clone(),
start,
seen: HashSet::new(),
seen_meta: HashSet::new(),
savepoints: Vec::new(),
});
}
None => {
let dir = crate::safety::workspace_root()
.join(".ironshell")
.join("tx")
.join(&id);
std::fs::create_dir_all(&dir)
.map_err(|e| crate::safety::fs_error("tx_begin", &dir, &e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
.map_err(|e| crate::safety::fs_error("tx_begin", &dir, &e))?;
}
*g = Some(TxState {
dir,
undos: Vec::new(),
n: 0,
frames: vec![Frame {
id: id.clone(),
start: 0,
seen: HashSet::new(),
seen_meta: HashSet::new(),
savepoints: Vec::new(),
}],
});
}
}
Ok(id)
}
pub fn snapshot(path: &str) {
let mut g = match TX.lock() {
Ok(g) => g,
Err(_) => return,
};
let tx = match g.as_mut() {
Some(t) => t,
None => return,
};
let abs = absolutize(path);
let already_captured = match tx.frames.last_mut() {
Some(f) => !f.seen.insert(abs.clone()),
None => return,
};
if already_captured {
return;
}
let existed = std::fs::symlink_metadata(&abs).is_ok();
let is_dir = existed && abs.is_dir();
let backup = if existed && abs.is_file() {
let bpath = tx.dir.join(format!("b{}", tx.n));
tx.n += 1;
copy_file(&abs, &bpath).ok().map(|_| bpath)
} else if is_dir {
let bpath = tx.dir.join(format!("d{}", tx.n));
tx.n += 1;
copy_tree(&abs, &bpath).ok().map(|_| bpath)
} else {
None
};
tx.undos.push(Undo {
original: abs,
backup,
existed,
is_dir,
meta: None,
});
}
pub fn snapshot_metadata(path: &str) {
let mut g = match TX.lock() {
Ok(g) => g,
Err(_) => return,
};
let tx = match g.as_mut() {
Some(t) => t,
None => return,
};
let abs = absolutize(path);
let md = match std::fs::metadata(&abs) {
Ok(m) => m,
Err(_) => return,
};
match tx.frames.last_mut() {
Some(f) => {
if !f.seen_meta.insert(abs.clone()) {
return;
}
}
None => return,
}
#[cfg(unix)]
let (uid, gid) = {
use std::os::unix::fs::MetadataExt;
(md.uid(), md.gid())
};
tx.undos.push(Undo {
original: abs,
backup: None,
existed: true,
is_dir: false,
meta: Some(MetaSnap {
permissions: md.permissions(),
#[cfg(unix)]
uid,
#[cfg(unix)]
gid,
modified: md.modified().ok(),
accessed: md.accessed().ok(),
}),
});
}
pub fn commit() -> Result<usize> {
let mut g = TX
.lock()
.map_err(|_| crate::safety::lock_poisoned("tx_commit", "the transaction state"))?;
let tx = g.as_mut().ok_or_else(|| {
crate::safety::bad_state("tx_commit", "no active transaction", "tx_begin")
})?;
let frame = tx.frames.pop().ok_or_else(|| {
crate::safety::bad_state("tx_commit", "no active transaction", "tx_begin")
})?;
let ops = tx.undos.len().saturating_sub(frame.start);
if tx.frames.is_empty() {
let dir = tx.dir.clone();
*g = None;
discard_journal(&dir);
}
Ok(ops)
}
pub fn rollback() -> Result<usize> {
let mut g = TX
.lock()
.map_err(|_| crate::safety::lock_poisoned("tx_rollback", "the transaction state"))?;
let tx = g.as_mut().ok_or_else(|| {
crate::safety::bad_state("tx_rollback", "no active transaction", "tx_begin")
})?;
let frame = tx.frames.pop().ok_or_else(|| {
crate::safety::bad_state("tx_rollback", "no active transaction", "tx_begin")
})?;
let tail: Vec<Undo> = tx.undos.drain(frame.start..).collect();
let mut restored = 0usize;
let mut failures = Vec::new();
for u in tail.iter().rev() {
match restore_one(u) {
Ok(true) => restored += 1,
Ok(false) => {}
Err(why) => failures.push(why),
}
}
let mut kept = None;
if tx.frames.is_empty() {
let dir = tx.dir.clone();
*g = None;
if failures.is_empty() {
discard_journal(&dir);
} else {
kept = Some(dir);
}
}
if !failures.is_empty() {
return Err(rollback_incomplete(
"tx_rollback",
&failures,
kept.as_deref(),
));
}
Ok(restored)
}
pub fn savepoint(name: &str) -> Result<()> {
let mut g = TX
.lock()
.map_err(|_| crate::safety::lock_poisoned("tx_savepoint", "the transaction state"))?;
let tx = g.as_mut().ok_or_else(|| {
crate::safety::bad_state("tx_savepoint", "no active transaction", "tx_begin")
})?;
let idx = tx.undos.len();
let frame = tx.frames.last_mut().ok_or_else(|| {
crate::safety::bad_state("tx_savepoint", "no active transaction", "tx_begin")
})?;
frame.savepoints.push((name.to_string(), idx));
frame.seen.clear();
frame.seen_meta.clear();
Ok(())
}
pub fn rollback_to(name: &str) -> Result<usize> {
let mut g = TX
.lock()
.map_err(|_| crate::safety::lock_poisoned("tx_rollback_to", "the transaction state"))?;
let tx = g.as_mut().ok_or_else(|| {
crate::safety::bad_state("tx_rollback_to", "no active transaction", "tx_begin")
})?;
let (idx, pos) = {
let frame = tx.frames.last().ok_or_else(|| {
crate::safety::bad_state("tx_rollback_to", "no active transaction", "tx_begin")
})?;
let pos = frame
.savepoints
.iter()
.rposition(|(n, _)| n == name)
.ok_or_else(|| {
crate::safety::bad_state_with_hint(
"tx_rollback_to",
&format!("no savepoint named '{}'", name),
"tx_savepoint(name) inside the transaction",
)
})?;
(frame.savepoints[pos].1, pos)
};
let tail: Vec<Undo> = tx.undos.drain(idx..).collect();
let mut restored = 0usize;
let mut failures = Vec::new();
{
let frame = tx.frames.last_mut().unwrap();
for u in tail.iter().rev() {
if u.meta.is_some() {
frame.seen_meta.remove(&u.original);
} else {
frame.seen.remove(&u.original);
}
match restore_one(u) {
Ok(true) => restored += 1,
Ok(false) => {}
Err(why) => failures.push(why),
}
}
frame.savepoints.truncate(pos + 1);
}
if !failures.is_empty() {
return Err(rollback_incomplete("tx_rollback_to", &failures, None));
}
Ok(restored)
}