diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/mod.rs | 10 | ||||
| -rw-r--r-- | src/app/session.rs | 25 | ||||
| -rw-r--r-- | src/persist.rs | 4 | ||||
| -rw-r--r-- | src/persist/io.rs | 83 | ||||
| -rw-r--r-- | src/persist/snapshot.rs | 8 | ||||
| -rw-r--r-- | src/persist/writer.rs | 461 |
6 files changed, 523 insertions, 68 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs index 4ac109c7..757a7f66 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -138,6 +138,7 @@ pub struct App { pub(crate) pending_agent_resume_deadline: Option<Instant>, pub(crate) session_save_deadline: Option<Instant>, pub(crate) session_save_thread: Option<std::thread::JoinHandle<()>>, + session_writer: Arc<std::sync::Mutex<crate::persist::SessionWriter>>, pane_exit_checkpoint_pending: bool, pub(crate) detached_process_children: Vec<std::process::Child>, tab_bar_status_generation: u64, @@ -370,9 +371,11 @@ impl App { // Try to restore previous session let mut restored_terminals = std::collections::HashMap::new(); let mut restored_terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); - let (workspaces, active, selected) = if !policy.restore_session { - (Vec::new(), None, 0) - } else if let Some(snap) = crate::persist::load() { + let snapshot = policy.restore_session.then(crate::persist::load).flatten(); + let session_writer = Arc::new(std::sync::Mutex::new(crate::persist::SessionWriter::new( + policy.restore_session && snapshot.is_none(), + ))); + let (workspaces, active, selected) = if let Some(snap) = snapshot { let history = config .experimental .pane_history @@ -599,6 +602,7 @@ impl App { pending_agent_resume_deadline: None, session_save_deadline: None, session_save_thread: None, + session_writer, pane_exit_checkpoint_pending: false, detached_process_children: Vec::new(), tab_bar_status_generation: 0, diff --git a/src/app/session.rs b/src/app/session.rs index 49dca914..08c4cd44 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -70,14 +70,15 @@ impl App { let job = self.capture_session_save_job(); self.pane_exit_checkpoint_pending = false; self.session_save_deadline = None; + let writer = self.session_writer.clone(); match std::thread::Builder::new() .name("herdr-session-save".into()) - .spawn(move || run_session_save_job(job)) + .spawn(move || run_session_save_job(job, &writer)) { Ok(thread) => self.session_save_thread = Some(thread), Err(err) => { tracing::warn!(err = %err, "failed to spawn session save thread; saving inline"); - run_session_save_job(self.capture_session_save_job()); + run_session_save_job(self.capture_session_save_job(), &self.session_writer); } } } @@ -92,7 +93,7 @@ impl App { return; } - run_session_save_job(self.capture_session_save_job()); + run_session_save_job(self.capture_session_save_job(), &self.session_writer); self.pane_exit_checkpoint_pending = false; self.session_save_deadline = None; } @@ -124,11 +125,19 @@ impl App { } } -fn run_session_save_job(job: SessionSaveJob) { - match job { - SessionSaveJob::Clear => crate::persist::clear(), - SessionSaveJob::Save { snapshot, history } => { - crate::persist::save(&snapshot, history.as_ref()); +fn run_session_save_job( + job: SessionSaveJob, + writer: &std::sync::Mutex<crate::persist::SessionWriter>, +) { + let mut writer = match writer.lock() { + Ok(writer) => writer, + Err(err) => { + tracing::warn!(err = %err, "session writer is poisoned; refusing to modify session"); + return; } + }; + match job { + SessionSaveJob::Clear => writer.clear(), + SessionSaveJob::Save { snapshot, history } => writer.save(&snapshot, history.as_ref()), } } diff --git a/src/persist.rs b/src/persist.rs index 822af263..c95355e6 100644 --- a/src/persist.rs +++ b/src/persist.rs @@ -8,8 +8,9 @@ mod io; pub mod plugin_registry; mod restore; mod snapshot; +mod writer; -pub use self::io::{clear, clear_history, load, load_history, save}; +pub use self::io::{clear_history, load, load_history}; pub use self::restore::restore; #[cfg(unix)] pub use self::restore::{handoff_pane_aliases, restore_handoff}; @@ -17,3 +18,4 @@ pub use self::snapshot::{ capture, capture_history, DirectionSnapshot, LayoutSnapshot, SessionHistorySnapshot, SessionSnapshot, TabSnapshot, WorkspaceSnapshot, }; +pub(crate) use self::writer::SessionWriter; diff --git a/src/persist/io.rs b/src/persist/io.rs index 3344f5ec..6f5ed459 100644 --- a/src/persist/io.rs +++ b/src/persist/io.rs @@ -7,7 +7,7 @@ use super::snapshot::{ SessionSnapshot, SNAPSHOT_VERSION, }; -fn session_path() -> PathBuf { +pub(super) fn session_path() -> PathBuf { crate::session::data_dir().join("session.json") } @@ -60,19 +60,14 @@ fn save_json_to_path<T: serde::Serialize>(path: &Path, snapshot: &T) -> std::io: Ok(()) } -pub(super) fn save_to_paths( - session_path: &Path, - history_path: &Path, - snapshot: &SessionSnapshot, +pub(super) fn save_history_to_path( + path: &Path, history: Option<&SessionHistorySnapshot>, ) -> std::io::Result<()> { - save_to_path(session_path, snapshot)?; - if let Some(history) = history { - save_json_to_path(history_path, history)?; - } else { - clear_path(history_path)?; + match history { + Some(history) => save_json_to_path(path, history), + None => clear_path(path), } - Ok(()) } pub(super) fn clear_path(path: &Path) -> std::io::Result<()> { @@ -83,26 +78,6 @@ pub(super) fn clear_path(path: &Path) -> std::io::Result<()> { } } -pub fn save(snapshot: &SessionSnapshot, history: Option<&SessionHistorySnapshot>) { - let path = session_path(); - let history_path = session_history_path(); - if let Err(err) = save_to_paths(&path, &history_path, snapshot, history) { - crate::logging::session_save_failed(&path, &err.to_string()); - return; - } - crate::logging::session_saved(&path, snapshot.workspaces.len()); -} - -pub fn clear() { - let path = session_path(); - if let Err(err) = clear_path(&path) { - crate::logging::session_clear_failed(&path, &err.to_string()); - return; - } - clear_history(); - crate::logging::session_cleared(&path); -} - pub fn clear_history() { let path = session_history_path(); if let Err(err) = clear_path(&path) { @@ -112,13 +87,20 @@ pub fn clear_history() { pub fn load() -> Option<SessionSnapshot> { let path = session_path(); - if !path.exists() { - return None; - } let content = match std::fs::read_to_string(&path) { Ok(content) => content, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + tracing::info!( + event = "persist.restore", subsystem = "persist", outcome = "missing", + path = %path.display(), "session file is missing" + ); + return None; + } Err(err) => { - warn!(err = %err, "failed to read session file"); + warn!( + event = "persist.restore", subsystem = "persist", outcome = "read_error", + path = %path.display(), err = %err, "failed to read session file" + ); return None; } }; @@ -128,14 +110,17 @@ pub fn load() -> Option<SessionSnapshot> { if let Some(version) = snapshot_file_version(&content) { if version > SNAPSHOT_VERSION { warn!( - file_version = version, - supported = SNAPSHOT_VERSION, + event = "persist.restore", subsystem = "persist", outcome = "unsupported_version", + path = %path.display(), file_version = version, supported = SNAPSHOT_VERSION, "session file is from a newer herdr version, ignoring" ); return None; } } - warn!(err = %err, "failed to parse session file, ignoring"); + warn!( + event = "persist.restore", subsystem = "persist", outcome = "parse_error", + path = %path.display(), err = %err, "failed to parse session file, ignoring" + ); None } } @@ -231,13 +216,8 @@ mod tests { fn save_to_paths_writes_pane_history_only_to_history_file() { let (session_path, history_path) = temp_session_paths("split-history"); - save_to_paths( - &session_path, - &history_path, - &empty_snapshot(), - Some(&history_snapshot("split-secret")), - ) - .unwrap(); + save_to_path(&session_path, &empty_snapshot()).unwrap(); + save_history_to_path(&history_path, Some(&history_snapshot("split-secret"))).unwrap(); let session = std::fs::read_to_string(&session_path).unwrap(); let history = std::fs::read_to_string(&history_path).unwrap(); @@ -249,15 +229,10 @@ mod tests { #[test] fn save_to_paths_removes_stale_history_when_history_is_disabled() { let (session_path, history_path) = temp_session_paths("clear-history"); - save_to_paths( - &session_path, - &history_path, - &empty_snapshot(), - Some(&history_snapshot("stale-secret")), - ) - .unwrap(); - - save_to_paths(&session_path, &history_path, &empty_snapshot(), None).unwrap(); + save_to_path(&session_path, &empty_snapshot()).unwrap(); + save_history_to_path(&history_path, Some(&history_snapshot("stale-secret"))).unwrap(); + + save_history_to_path(&history_path, None).unwrap(); assert!(session_path.exists()); assert!(!history_path.exists()); diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs index e6ca70f9..07aaf3b9 100644 --- a/src/persist/snapshot.rs +++ b/src/persist/snapshot.rs @@ -468,9 +468,13 @@ pub(super) fn parse_history_snapshot(content: &str) -> Result<SessionHistorySnap } pub(super) fn snapshot_file_version(content: &str) -> Option<u32> { - serde_json::from_str::<RawSessionSnapshot>(content) + #[derive(Deserialize)] + struct Header { + version: u32, + } + serde_json::from_str::<Header>(content) .ok() - .map(|raw| raw.version) + .map(|header| header.version) } #[cfg(test)] diff --git a/src/persist/writer.rs b/src/persist/writer.rs new file mode 100644 index 00000000..48bbc496 --- /dev/null +++ b/src/persist/writer.rs @@ -0,0 +1,461 @@ +use std::fs::File; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use super::{SessionHistorySnapshot, SessionSnapshot}; + +/// Shared by autosave, pane-exit checkpoints, and shutdown. +pub(crate) struct SessionWriter { + path: PathBuf, + protect_unloaded: bool, +} + +impl SessionWriter { + pub(crate) fn new(protect_unloaded: bool) -> Self { + Self { + path: super::io::session_path(), + protect_unloaded, + } + } + + fn preserve_unloaded(&mut self) -> io::Result<()> { + if self.protect_unloaded && preserve_existing(&self.path)? { + self.protect_unloaded = false; + } + Ok(()) + } + + pub(crate) fn save( + &mut self, + snapshot: &SessionSnapshot, + history: Option<&SessionHistorySnapshot>, + ) { + let result = self + .preserve_unloaded() + .and_then(|()| super::io::save_to_path(&self.path, snapshot)); + if let Err(err) = result { + crate::logging::session_save_failed(&self.path, &err.to_string()); + return; + } + // Optional history failure must not reclassify our committed layout as unloaded. + self.protect_unloaded = false; + let history_path = self.path.with_file_name("session-history.json"); + if let Err(err) = super::io::save_history_to_path(&history_path, history) { + crate::logging::session_save_failed(&history_path, &err.to_string()); + } + crate::logging::session_saved(&self.path, snapshot.workspaces.len()); + } + + pub(crate) fn clear(&mut self) { + let result = self + .preserve_unloaded() + .and_then(|()| super::io::clear_path(&self.path)); + if let Err(err) = result { + crate::logging::session_clear_failed(&self.path, &err.to_string()); + return; + } + let history_path = self.path.with_file_name("session-history.json"); + if let Err(err) = super::io::clear_path(&history_path) { + crate::logging::session_clear_failed(&history_path, &err.to_string()); + } + crate::logging::session_cleared(&self.path); + } +} + +fn preserve_existing(path: &Path) -> io::Result<bool> { + let mut source = match File::open(path) { + Ok(file) => file, + // Recheck on the next mutation until a fresh session is actually saved. + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(err) => return Err(err), + }; + if !source.metadata()?.is_file() { + return Err(io::Error::other("session path is not a regular file")); + } + let directory = path.with_file_name("session-backups"); + std::fs::create_dir_all(&directory)?; + let older = recovery_files(&directory)?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + // Keep creation order even when the wall clock moves backwards. + let timestamp = match older.last() { + Some((previous, _)) => now.max( + previous + .checked_add(1) + .ok_or_else(|| io::Error::other("session recovery sequence exhausted"))?, + ), + None => now, + }; + for sequence in 0..128 { + let backup = directory.join(format!( + "session-{timestamp:039}-{}-{sequence}.json", + std::process::id() + )); + match copy_recovery(&mut source, &backup) { + Ok(()) => {} + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err), + } + tracing::info!( + event = "persist.backup", + subsystem = "persist", + outcome = "ok", + path = %path.display(), + backup_path = %backup.display(), + "preserved unloaded session before replacement" + ); + if let Err(err) = prune_backups(&older) { + tracing::warn!( + event = "persist.backup", subsystem = "persist", outcome = "prune_error", + path = %directory.display(), err = %err, "failed to prune session recovery copies" + ); + } + return Ok(true); + } + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "could not allocate session recovery copy", + )) +} + +fn copy_recovery(source: &mut impl io::Read, backup: &Path) -> io::Result<()> { + let directory = backup.parent().unwrap_or_else(|| Path::new(".")); + let pending = backup.with_extension("pending"); + let mut output = crate::platform::create_config_temporary(&pending, true)?; + let mut published = false; + let result = (|| { + match std::fs::symlink_metadata(backup) { + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => return Err(err), + Ok(_) => { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "recovery copy already exists", + )) + } + } + io::copy(source, &mut output)?; + output.sync_all()?; + drop(output); + std::fs::rename(&pending, backup)?; + published = true; + crate::platform::sync_parent_directory(directory)?; + crate::platform::sync_parent_directory(directory.parent().unwrap_or_else(|| Path::new("."))) + })(); + if result.is_err() { + let _ = std::fs::remove_file(pending); + if published { + let _ = std::fs::remove_file(backup); + } + } + result +} + +fn recovery_files(directory: &Path) -> io::Result<Vec<(u128, PathBuf)>> { + let mut files = Vec::new(); + for entry in std::fs::read_dir(directory)? { + let entry = entry?; + if entry.file_type()?.is_file() { + if let Some(timestamp) = entry.file_name().to_str().and_then(recovery_timestamp) { + files.push((timestamp, entry.path())); + } + } + } + files.sort(); + Ok(files) +} + +fn prune_backups(older: &[(u128, PathBuf)]) -> io::Result<()> { + // The new copy is durable before any of the previous copies are removed. + for (_, path) in older.iter().take(older.len().saturating_sub(2)) { + std::fs::remove_file(path)?; + } + Ok(()) +} + +fn recovery_timestamp(name: &str) -> Option<u128> { + let fields = name.strip_prefix("session-")?.strip_suffix(".json")?; + let fields: Vec<_> = fields.split('-').collect(); + if fields.len() == 3 + && fields[0].len() == 39 + && fields + .iter() + .all(|field| !field.is_empty() && field.bytes().all(|byte| byte.is_ascii_digit())) + { + fields[0].parse().ok() + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn writer(protect_unloaded: bool) -> SessionWriter { + let directory = std::env::temp_dir().join(format!( + "herdr-session-recovery-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&directory).unwrap(); + SessionWriter { + path: directory.join("session.json"), + protect_unloaded, + } + } + + fn snapshot() -> SessionSnapshot { + serde_json::from_str(include_str!( + "../../tests/fixtures/session/current-herdr-session.json" + )) + .unwrap() + } + + fn backups(writer: &SessionWriter) -> Vec<Vec<u8>> { + let directory = writer.path.with_file_name("session-backups"); + if !directory.exists() { + return Vec::new(); + } + let mut entries: Vec<_> = std::fs::read_dir(directory) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect(); + entries.sort(); + entries + .into_iter() + .map(|path| std::fs::read(path).unwrap()) + .collect() + } + + #[test] + fn healthy_and_fresh_sessions_save_and_clear_without_backups() { + for protect_unloaded in [false, true] { + let mut writer = writer(protect_unloaded); + if !protect_unloaded { + super::super::io::save_to_path(&writer.path, &snapshot()).unwrap(); + } + writer.save(&snapshot(), None); + assert!(!writer.protect_unloaded); + assert!(writer.path.exists()); + writer.save(&snapshot(), None); + writer.clear(); + assert!(!writer.path.exists()); + assert!(backups(&writer).is_empty()); + std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap(); + } + } + + #[test] + fn failed_recovery_blocks_mutations_then_retries_preserving_exact_bytes_once() { + let original = b"invalid utf8 \xff"; + let mut writer = writer(true); + std::fs::write(&writer.path, original).unwrap(); + let history_path = writer.path.with_file_name("session-history.json"); + std::fs::write(&history_path, b"history").unwrap(); + let directory = writer.path.with_file_name("session-backups"); + std::fs::write(&directory, b"blocks recovery").unwrap(); + writer.save(&snapshot(), None); + writer.clear(); + assert!(writer.protect_unloaded); + assert_eq!(std::fs::read(&writer.path).unwrap(), original); + assert_eq!(std::fs::read(&history_path).unwrap(), b"history"); + + std::fs::remove_file(&directory).unwrap(); + writer.save(&snapshot(), None); + assert!(!writer.protect_unloaded); + writer.save(&snapshot(), None); + writer.clear(); + assert!(!writer.path.exists()); + assert_eq!(backups(&writer), vec![original.to_vec()]); + std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap(); + } + + #[test] + fn optional_history_failure_does_not_block_later_layout_saves() { + let mut writer = writer(true); + let history = writer.path.with_file_name("session-history.json"); + std::fs::create_dir(&history).unwrap(); + std::fs::write( + writer.path.with_file_name("session-backups"), + b"unavailable", + ) + .unwrap(); + writer.save(&snapshot(), None); + assert!( + !writer.protect_unloaded, + "structural session was saved successfully" + ); + let mut changed = snapshot(); + changed.workspaces[0].custom_name = Some("latest layout".into()); + writer.save(&changed, None); + let saved: SessionSnapshot = + serde_json::from_slice(&std::fs::read(&writer.path).unwrap()).unwrap(); + assert_eq!( + saved.workspaces[0].custom_name.as_deref(), + Some("latest layout") + ); + std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap(); + } + + #[test] + fn pruning_leaves_user_named_recovery_files_alone() { + let mut writer = writer(true); + let directory = writer.path.with_file_name("session-backups"); + std::fs::create_dir(&directory).unwrap(); + let manual = directory.join("session-000-manual.json"); + std::fs::write(&manual, b"manual recovery copy").unwrap(); + for i in 0..5u8 { + writer.protect_unloaded = true; + std::fs::write(&writer.path, [i]).unwrap(); + writer.save(&snapshot(), None); + } + assert_eq!(std::fs::read(manual).unwrap(), b"manual recovery copy"); + assert_eq!(std::fs::read_dir(directory).unwrap().count(), 4); + std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap(); + } + + #[test] + fn first_clear_preserves_an_unloaded_file_even_after_an_earlier_missing_clear() { + let mut writer = writer(true); + writer.clear(); + assert!(writer.protect_unloaded); + std::fs::write(&writer.path, b"late layout").unwrap(); + writer.clear(); + assert!(!writer.path.exists()); + assert_eq!(backups(&writer), vec![b"late layout".to_vec()]); + std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap(); + } + + #[test] + fn repeated_failed_saves_do_not_replace_a_completed_recovery_copy() { + let mut writer = writer(true); + std::fs::write(&writer.path, b"original").unwrap(); + let temporary = writer.path.with_extension("json.tmp"); + std::fs::create_dir(&temporary).unwrap(); + writer.save(&snapshot(), None); + writer.save(&snapshot(), None); + assert_eq!(std::fs::read(&writer.path).unwrap(), b"original"); + std::fs::remove_dir(&temporary).unwrap(); + writer.save(&snapshot(), None); + assert_eq!(backups(&writer), vec![b"original".to_vec()]); + std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap(); + } + + #[test] + fn interrupted_copy_is_not_published_as_a_recovery_file() { + use std::io::Read; + struct Interrupted; + impl Read for Interrupted { + fn read(&mut self, _: &mut [u8]) -> io::Result<usize> { + panic!("interrupt the copy after writing its prefix"); + } + } + let writer = writer(true); + let backup = writer + .path + .with_file_name("session-000000000000000000000000000000000000001-1-0.json"); + let mut source = io::Cursor::new(b"partial").chain(Interrupted); + assert!(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + copy_recovery(&mut source, &backup) + })) + .is_err()); + assert!( + !backup.exists(), + "an interrupted copy must not look complete" + ); + assert_eq!( + std::fs::read(backup.with_extension("pending")).unwrap(), + b"partial" + ); + assert!(recovery_files(writer.path.parent().unwrap()) + .unwrap() + .is_empty()); + std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap(); + } + + #[test] + fn recovery_order_survives_clock_rollback() { + let mut writer = writer(true); + let directory = writer.path.with_file_name("session-backups"); + std::fs::create_dir(&directory).unwrap(); + let future = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + + 1_000_000_000_000_000; + for i in 0..2u8 { + std::fs::write( + directory.join(format!("session-{:039}-1-0.json", future + u128::from(i))), + [i], + ) + .unwrap(); + } + for i in 2..4u8 { + writer.protect_unloaded = true; + std::fs::write(&writer.path, [i]).unwrap(); + writer.save(&snapshot(), None); + } + assert_eq!(backups(&writer), vec![vec![1], vec![2], vec![3]]); + std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap(); + } + + #[test] + fn recovery_keeps_three_copies_and_healthy_saves_do_not_rotate_them() { + let mut writer = writer(true); + for i in 0..5u8 { + writer.protect_unloaded = true; + std::fs::write(&writer.path, [i]).unwrap(); + writer.save(&snapshot(), None); + } + assert_eq!(backups(&writer), vec![vec![2], vec![3], vec![4]]); + writer.save(&snapshot(), None); + writer.clear(); + assert_eq!(backups(&writer), vec![vec![2], vec![3], vec![4]]); + std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap(); + } + + #[cfg(unix)] + #[test] + fn dangling_symlink_allows_first_save_and_late_target_is_preserved() { + use std::os::unix::{fs::symlink, fs::PermissionsExt}; + for late_target in [false, true] { + let mut writer = writer(true); + let target = writer.path.with_file_name("target.json"); + symlink("target.json", &writer.path).unwrap(); + if late_target { + std::fs::write(&target, b"late layout").unwrap(); + } + writer.save(&snapshot(), None); + assert!(std::fs::symlink_metadata(&writer.path) + .unwrap() + .file_type() + .is_symlink()); + assert!(target.exists()); + if late_target { + assert_eq!(backups(&writer), vec![b"late layout".to_vec()]); + let backup = std::fs::read_dir(writer.path.with_file_name("session-backups")) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + assert_eq!( + std::fs::metadata(backup).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } else { + assert!(backups(&writer).is_empty()); + } + writer.clear(); + assert!(target.exists()); + std::fs::remove_dir_all(writer.path.parent().unwrap()).unwrap(); + } + } +} |
