mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-17 16:37:20 +02:00
refactor: improve ephemeral ux
This commit is contained in:
@@ -1316,7 +1316,12 @@ impl BrowserRunner {
|
||||
// disk instead of the previous snapshot.
|
||||
crate::profile::password::complete_after_quit_and_wait(profile).await;
|
||||
} else if profile.ephemeral {
|
||||
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
|
||||
let id = profile.id.to_string();
|
||||
crate::ephemeral_dirs::remove_ephemeral_dir(&id);
|
||||
// The per-domain traffic tracker writes to the cache dir on real disk
|
||||
// regardless of where the profile itself lives, so an "in memory only"
|
||||
// session still left a full record of everywhere it connected.
|
||||
crate::traffic_stats::delete_traffic_stats(&id);
|
||||
} else if profile.clear_on_close {
|
||||
// Awaited for the same reason as re-encryption above: a queued sync
|
||||
// must see the cleared dir, not the pre-clear snapshot.
|
||||
|
||||
+273
-37
@@ -4,44 +4,110 @@ use std::sync::Mutex;
|
||||
|
||||
use crate::profile::BrowserProfile;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref EPHEMERAL_DIRS: Mutex<HashMap<String, PathBuf>> = Mutex::new(HashMap::new());
|
||||
/// Whether an ephemeral directory is genuinely in memory, or was downgraded to
|
||||
/// real disk because RAM backing could not be obtained.
|
||||
///
|
||||
/// This has to be recorded when the directory is created, not guessed when it
|
||||
/// is destroyed: by teardown time the RAM disk may have been unmounted, and a
|
||||
/// path alone cannot say what it used to be. The erase path reads it to decide
|
||||
/// whether overwriting is meaningful or just page churn.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EphemeralBacking {
|
||||
Ram,
|
||||
Disk,
|
||||
}
|
||||
|
||||
/// Get or create the RAM-backed base directory for ephemeral profiles.
|
||||
/// Linux: /dev/shm (always tmpfs). macOS: RAM disk via hdiutil. Windows: imdisk RAM disk.
|
||||
fn get_ephemeral_base_dir() -> Result<PathBuf, String> {
|
||||
impl EphemeralBacking {
|
||||
/// Overwriting only means something when freed disk blocks are involved.
|
||||
fn needs_zeroing(self) -> bool {
|
||||
matches!(self, EphemeralBacking::Disk)
|
||||
}
|
||||
}
|
||||
|
||||
struct EphemeralEntry {
|
||||
path: PathBuf,
|
||||
backing: EphemeralBacking,
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref EPHEMERAL_DIRS: Mutex<HashMap<String, EphemeralEntry>> = Mutex::new(HashMap::new());
|
||||
}
|
||||
|
||||
/// Test-only redirect for the ephemeral base.
|
||||
///
|
||||
/// Without this the unit tests call the real resolver, which on macOS runs
|
||||
/// `hdiutil attach` + `diskutil erasevolume` and never detaches: a plain
|
||||
/// `cargo test` left a 256 MB RAM disk mounted on the developer's machine
|
||||
/// indefinitely. Deliberately compiled out of release builds, because an
|
||||
/// env-var redirect for a directory holding decrypted profile data is a
|
||||
/// capability nobody should be able to reach in a shipped binary.
|
||||
#[cfg(any(test, debug_assertions))]
|
||||
fn ephemeral_base_override() -> Option<PathBuf> {
|
||||
std::env::var_os("DONUTBROWSER_EPHEMERAL_ROOT")
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
#[cfg(not(any(test, debug_assertions)))]
|
||||
fn ephemeral_base_override() -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Get or create the base directory for ephemeral profiles, and report whether
|
||||
/// it is actually RAM-backed.
|
||||
///
|
||||
/// Linux: /dev/shm (always tmpfs). macOS: RAM disk via hdiutil. Windows: imdisk
|
||||
/// RAM disk, which is a third-party driver this app does not ship, so on most
|
||||
/// Windows machines the disk fallback is the normal path rather than an edge
|
||||
/// case. Callers must treat `Disk` as a downgrade and erase accordingly.
|
||||
fn get_ephemeral_base_dir() -> Result<(PathBuf, EphemeralBacking), String> {
|
||||
if let Some(base) = ephemeral_base_override() {
|
||||
std::fs::create_dir_all(&base)
|
||||
.map_err(|e| format!("Failed to create overridden ephemeral base: {e}"))?;
|
||||
return Ok((base, EphemeralBacking::Disk));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let base = PathBuf::from("/dev/shm/donut-ephemeral");
|
||||
std::fs::create_dir_all(&base)
|
||||
.map_err(|e| format!("Failed to create ephemeral base in /dev/shm: {e}"))?;
|
||||
Ok(base)
|
||||
Ok((base, EphemeralBacking::Ram))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let ramdisk_error: String;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Ok(mount) = get_or_create_macos_ramdisk() {
|
||||
return Ok(mount);
|
||||
match get_or_create_macos_ramdisk() {
|
||||
Ok(mount) => return Ok((mount, EphemeralBacking::Ram)),
|
||||
Err(e) => ramdisk_error = e,
|
||||
}
|
||||
log::warn!("Failed to create macOS RAM disk, ephemeral profiles may use disk");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Ok(mount) = get_or_create_windows_ramdisk() {
|
||||
return Ok(mount);
|
||||
match get_or_create_windows_ramdisk() {
|
||||
Ok(mount) => return Ok((mount, EphemeralBacking::Ram)),
|
||||
Err(e) => ramdisk_error = e,
|
||||
}
|
||||
log::warn!("Failed to create Windows RAM disk, ephemeral profiles may use disk");
|
||||
}
|
||||
|
||||
// Fallback
|
||||
// Downgraded to real disk. This is logged at error, with the cause and the
|
||||
// destination, because the profile no longer keeps the promise its name
|
||||
// makes and the previous "may use disk" wording was logged unconditionally
|
||||
// right before disk was used, so it read as speculative when it was
|
||||
// certain. The cause used to be discarded entirely.
|
||||
let base = std::env::temp_dir().join("donut-ephemeral");
|
||||
std::fs::create_dir_all(&base)
|
||||
.map_err(|e| format!("Failed to create ephemeral base dir: {e}"))?;
|
||||
Ok(base)
|
||||
log::error!(
|
||||
"No RAM disk available ({ramdisk_error}); ephemeral profiles are being written to disk at {} and will be securely erased on teardown instead",
|
||||
base.display()
|
||||
);
|
||||
Ok((base, EphemeralBacking::Disk))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +201,7 @@ fn get_or_create_windows_ramdisk() -> Result<PathBuf, String> {
|
||||
}
|
||||
|
||||
pub fn create_ephemeral_dir(profile_id: &str) -> Result<PathBuf, String> {
|
||||
let base = get_ephemeral_base_dir()?;
|
||||
let (base, backing) = get_ephemeral_base_dir()?;
|
||||
let dir_path = base.join(profile_id);
|
||||
|
||||
std::fs::create_dir_all(&dir_path).map_err(|e| format!("Failed to create ephemeral dir: {e}"))?;
|
||||
@@ -143,10 +209,23 @@ pub fn create_ephemeral_dir(profile_id: &str) -> Result<PathBuf, String> {
|
||||
EPHEMERAL_DIRS
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock ephemeral dirs: {e}"))?
|
||||
.insert(profile_id.to_string(), dir_path.clone());
|
||||
.insert(
|
||||
profile_id.to_string(),
|
||||
EphemeralEntry {
|
||||
path: dir_path.clone(),
|
||||
backing,
|
||||
},
|
||||
);
|
||||
|
||||
// State the backing on every launch. Previously only the failure path said
|
||||
// anything, so a log could not be used to tell a RAM-backed session from a
|
||||
// disk-backed one after the fact.
|
||||
log::info!(
|
||||
"Created ephemeral dir for profile {}: {}",
|
||||
"Created {} ephemeral dir for profile {}: {}",
|
||||
match backing {
|
||||
EphemeralBacking::Ram => "RAM-backed",
|
||||
EphemeralBacking::Disk => "DISK-backed (not in memory)",
|
||||
},
|
||||
profile_id,
|
||||
dir_path.display()
|
||||
);
|
||||
@@ -155,26 +234,67 @@ pub fn create_ephemeral_dir(profile_id: &str) -> Result<PathBuf, String> {
|
||||
}
|
||||
|
||||
pub fn get_ephemeral_dir(profile_id: &str) -> Option<PathBuf> {
|
||||
EPHEMERAL_DIRS.lock().ok()?.get(profile_id).cloned()
|
||||
Some(EPHEMERAL_DIRS.lock().ok()?.get(profile_id)?.path.clone())
|
||||
}
|
||||
|
||||
pub fn remove_ephemeral_dir(profile_id: &str) {
|
||||
let dir = EPHEMERAL_DIRS
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut map| map.remove(profile_id));
|
||||
/// Destroy a profile's ephemeral directory, zeroing it first when it is on real
|
||||
/// disk.
|
||||
///
|
||||
/// Returns false when data was knowingly left behind. The mapping is only
|
||||
/// dropped on success: removing it first (as this used to) meant a failure,
|
||||
/// which on Windows is as ordinary as a file still being locked by an exiting
|
||||
/// browser, discarded the only handle to the directory and guaranteed nothing
|
||||
/// would ever retry it.
|
||||
pub fn remove_ephemeral_dir(profile_id: &str) -> bool {
|
||||
let entry = match EPHEMERAL_DIRS.lock() {
|
||||
Ok(map) => map.get(profile_id).map(|e| (e.path.clone(), e.backing)),
|
||||
Err(e) => {
|
||||
log::error!("Failed to lock ephemeral dirs while removing {profile_id}: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(dir_path) = dir {
|
||||
if dir_path.exists() {
|
||||
if let Err(e) = std::fs::remove_dir_all(&dir_path) {
|
||||
log::warn!("Failed to remove ephemeral dir {}: {e}", dir_path.display());
|
||||
} else {
|
||||
log::info!(
|
||||
"Removed ephemeral dir for profile {}: {}",
|
||||
profile_id,
|
||||
dir_path.display()
|
||||
);
|
||||
let Some((dir_path, backing)) = entry else {
|
||||
return true;
|
||||
};
|
||||
|
||||
if !dir_path.exists() {
|
||||
if let Ok(mut map) = EPHEMERAL_DIRS.lock() {
|
||||
map.remove(profile_id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let zero = backing.needs_zeroing();
|
||||
let started = std::time::Instant::now();
|
||||
match crate::fs_secure::secure_remove_dir_all(&dir_path, zero) {
|
||||
Ok(files) => {
|
||||
if let Ok(mut map) = EPHEMERAL_DIRS.lock() {
|
||||
map.remove(profile_id);
|
||||
}
|
||||
log::info!(
|
||||
"Removed {} ephemeral dir for profile {} ({} files, {} ms): {}",
|
||||
if zero {
|
||||
"and zeroed disk-backed"
|
||||
} else {
|
||||
"RAM-backed"
|
||||
},
|
||||
profile_id,
|
||||
files,
|
||||
started.elapsed().as_millis(),
|
||||
dir_path.display()
|
||||
);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
// Error, not warn: this is the case where the user's browsing data is
|
||||
// knowingly still on the machine. The mapping is kept so a later sweep
|
||||
// can try again.
|
||||
log::error!(
|
||||
"Failed to remove ephemeral dir {} for profile {profile_id}: {e}. Profile data is still on disk.",
|
||||
dir_path.display()
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,7 +305,7 @@ pub fn remove_ephemeral_dir(profile_id: &str) {
|
||||
pub fn recover_ephemeral_dirs() {
|
||||
cleanup_legacy_dirs();
|
||||
|
||||
let base = match get_ephemeral_base_dir() {
|
||||
let (base, backing) = match get_ephemeral_base_dir() {
|
||||
Ok(base) => base,
|
||||
Err(e) => {
|
||||
log::warn!("Cannot recover ephemeral dirs: {e}");
|
||||
@@ -193,6 +313,12 @@ pub fn recover_ephemeral_dirs() {
|
||||
}
|
||||
};
|
||||
|
||||
// Sweep the disk fallback even when this run resolved to a RAM disk. A
|
||||
// previous run that fell back left a full profile tree in the temp dir, and
|
||||
// once RAM backing works again the base points elsewhere and that residue
|
||||
// would never be looked at again.
|
||||
sweep_disk_fallback_residue(&base);
|
||||
|
||||
let entries = match std::fs::read_dir(&base) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return,
|
||||
@@ -207,7 +333,15 @@ pub fn recover_ephemeral_dirs() {
|
||||
if entry.path().is_dir() {
|
||||
if let Some(name) = entry.file_name().to_str() {
|
||||
if uuid::Uuid::parse_str(name).is_ok() {
|
||||
dirs.insert(name.to_string(), entry.path());
|
||||
dirs.insert(
|
||||
name.to_string(),
|
||||
EphemeralEntry {
|
||||
path: entry.path(),
|
||||
// Judge a recovered directory by the base it was found under, not
|
||||
// by what some earlier run happened to resolve.
|
||||
backing,
|
||||
},
|
||||
);
|
||||
log::info!("Recovered ephemeral dir for profile {}", name);
|
||||
}
|
||||
}
|
||||
@@ -215,6 +349,28 @@ pub fn recover_ephemeral_dirs() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Securely erase leftovers from a run that was downgraded to the disk
|
||||
/// fallback, unless that fallback is the base being used right now (in which
|
||||
/// case `recover_ephemeral_dirs` is about to adopt them instead).
|
||||
fn sweep_disk_fallback_residue(current_base: &Path) {
|
||||
let fallback = std::env::temp_dir().join("donut-ephemeral");
|
||||
if !fallback.exists() || fallback == current_base {
|
||||
return;
|
||||
}
|
||||
|
||||
match crate::fs_secure::secure_remove_dir_all(&fallback, true) {
|
||||
Ok(files) if files > 0 => log::info!(
|
||||
"Securely erased {files} file(s) of disk-backed ephemeral residue at {}",
|
||||
fallback.display()
|
||||
),
|
||||
Ok(_) => {}
|
||||
Err(e) => log::error!(
|
||||
"Failed to erase disk-backed ephemeral residue at {}: {e}",
|
||||
fallback.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove old-format ephemeral dirs from /tmp (pre-tmpfs migration).
|
||||
fn cleanup_legacy_dirs() {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
@@ -225,8 +381,10 @@ fn cleanup_legacy_dirs() {
|
||||
|
||||
for entry in entries.flatten() {
|
||||
if let Some(name) = entry.file_name().to_str() {
|
||||
// These are always in the system temp dir by construction, so they are
|
||||
// always on real disk and always worth zeroing.
|
||||
if name.starts_with("donut-ephemeral-") && entry.path().is_dir() {
|
||||
if let Err(e) = std::fs::remove_dir_all(entry.path()) {
|
||||
if let Err(e) = crate::fs_secure::secure_remove_dir_all(&entry.path(), true) {
|
||||
log::warn!("Failed to clean up legacy ephemeral dir: {e}");
|
||||
} else {
|
||||
log::info!(
|
||||
@@ -286,9 +444,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Point the ephemeral base at a scratch directory for the duration of a
|
||||
/// test. Without this the tests call the real resolver, which on macOS
|
||||
/// attaches a 256 MB RAM disk that nothing ever detaches, so running
|
||||
/// `cargo test` left one mounted on the developer's machine indefinitely.
|
||||
struct BaseGuard(tempfile::TempDir);
|
||||
|
||||
impl BaseGuard {
|
||||
fn new() -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::env::set_var("DONUTBROWSER_EPHEMERAL_ROOT", tmp.path());
|
||||
BaseGuard(tmp)
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
self.0.path()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BaseGuard {
|
||||
fn drop(&mut self) {
|
||||
std::env::remove_var("DONUTBROWSER_EPHEMERAL_ROOT");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_ephemeral_dir_lifecycle() {
|
||||
let _base = BaseGuard::new();
|
||||
// Clear global state to avoid interference from other tests
|
||||
EPHEMERAL_DIRS.lock().unwrap().clear();
|
||||
|
||||
@@ -321,7 +504,8 @@ mod tests {
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_recover_ephemeral_dirs() {
|
||||
let base = get_ephemeral_base_dir().unwrap();
|
||||
let _base = BaseGuard::new();
|
||||
let (base, _) = get_ephemeral_base_dir().unwrap();
|
||||
let test_id = uuid::Uuid::new_v4().to_string();
|
||||
let test_dir = base.join(&test_id);
|
||||
std::fs::create_dir_all(&test_dir).unwrap();
|
||||
@@ -336,4 +520,56 @@ mod tests {
|
||||
// Clean up
|
||||
remove_ephemeral_dir(&test_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn disk_backed_dirs_are_zeroed_and_ram_backed_ones_are_not() {
|
||||
let base = BaseGuard::new();
|
||||
EPHEMERAL_DIRS.lock().unwrap().clear();
|
||||
|
||||
// The override always reports Disk, which is the fail-safe: an unverified
|
||||
// base must be treated as if it were on a platter.
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let dir = create_ephemeral_dir(&id).unwrap();
|
||||
// Proves the override actually took effect, so this test can never be
|
||||
// silently exercising the developer's real RAM disk.
|
||||
assert!(dir.starts_with(base.path()));
|
||||
assert_eq!(
|
||||
EPHEMERAL_DIRS.lock().unwrap().get(&id).map(|e| e.backing),
|
||||
Some(EphemeralBacking::Disk)
|
||||
);
|
||||
assert!(EphemeralBacking::Disk.needs_zeroing());
|
||||
assert!(!EphemeralBacking::Ram.needs_zeroing());
|
||||
|
||||
std::fs::write(dir.join("Cookies"), b"session=secret").unwrap();
|
||||
assert!(remove_ephemeral_dir(&id));
|
||||
assert!(!dir.exists());
|
||||
assert!(get_ephemeral_dir(&id).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn removing_an_unknown_profile_succeeds_without_doing_anything() {
|
||||
let _base = BaseGuard::new();
|
||||
EPHEMERAL_DIRS.lock().unwrap().clear();
|
||||
assert!(remove_ephemeral_dir(&uuid::Uuid::new_v4().to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn the_mapping_survives_a_failed_removal_so_it_can_be_retried() {
|
||||
// The old code popped the entry before attempting the delete, so a failure
|
||||
// (a locked file on Windows, say) threw away the only handle to the
|
||||
// directory and nothing could ever retry it.
|
||||
let _base = BaseGuard::new();
|
||||
EPHEMERAL_DIRS.lock().unwrap().clear();
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let dir = create_ephemeral_dir(&id).unwrap();
|
||||
assert!(dir.exists());
|
||||
|
||||
// A successful removal is the one that clears the mapping.
|
||||
assert!(remove_ephemeral_dir(&id));
|
||||
assert!(get_ephemeral_dir(&id).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Best-effort secure deletion.
|
||||
//!
|
||||
//! "Best-effort" is load-bearing and is not a hedge. On copy-on-write
|
||||
//! filesystems (APFS, Btrfs, ZFS, ReFS) and on any SSD with wear levelling, the
|
||||
//! blocks holding the old contents may survive an overwrite entirely, because
|
||||
//! the write lands somewhere else. RAM-backed storage can also be paged out,
|
||||
//! and zeroing a file cannot reach the swap slot that held it. Treat these
|
||||
//! helpers as raising the cost of recovery, never as a guarantee of erasure.
|
||||
//!
|
||||
//! The only reliable erasure this codebase has is not writing plaintext to disk
|
||||
//! in the first place, which is what the RAM-backed ephemeral directories are
|
||||
//! for. These helpers exist for the paths where that failed.
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
/// Zero a file's bytes and flush before unlinking, so the contents are not
|
||||
/// trivially recoverable from the freed blocks.
|
||||
///
|
||||
/// The overwrite must never gate the unlink. A write that fails part-way
|
||||
/// (ENOSPC on a copy-on-write volume, EIO) would otherwise leave the file both
|
||||
/// un-wiped and un-deleted, which is strictly worse than the plain remove this
|
||||
/// replaces, because callers report success either way and the data would
|
||||
/// silently survive.
|
||||
pub fn secure_remove_file(path: &Path) -> std::io::Result<()> {
|
||||
if let Ok(meta) = fs::metadata(path) {
|
||||
let len = meta.len();
|
||||
if len > 0 {
|
||||
if let Ok(mut f) = fs::OpenOptions::new().write(true).open(path) {
|
||||
let zeros = vec![0u8; 64 * 1024];
|
||||
let mut remaining = len;
|
||||
while remaining > 0 {
|
||||
let chunk = remaining.min(zeros.len() as u64) as usize;
|
||||
if f.write_all(&zeros[..chunk]).is_err() {
|
||||
break;
|
||||
}
|
||||
remaining -= chunk as u64;
|
||||
}
|
||||
// One flush per file, not per chunk: syncing every 64 KiB turns a
|
||||
// profile teardown into thousands of barriers for no extra safety.
|
||||
let _ = f.flush();
|
||||
let _ = f.sync_all();
|
||||
}
|
||||
}
|
||||
}
|
||||
fs::remove_file(path)
|
||||
}
|
||||
|
||||
/// Whether zeroing this file would even mean anything.
|
||||
///
|
||||
/// A file with more than one hard link is still reachable through the other
|
||||
/// link, so overwriting it destroys live data somewhere else and erases
|
||||
/// nothing here.
|
||||
#[cfg(unix)]
|
||||
fn is_last_link(meta: &fs::Metadata) -> bool {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
meta.nlink() <= 1
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn is_last_link(_meta: &fs::Metadata) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Recursively delete a directory, optionally zeroing regular files first.
|
||||
///
|
||||
/// `zero` should be false for RAM-backed storage (tmpfs, a real RAM disk):
|
||||
/// there are no freed disk blocks to scrub, so overwriting is pure page churn
|
||||
/// and on a small fixed-size volume can hit ENOSPC. Pass true only when the
|
||||
/// tree is genuinely on disk.
|
||||
///
|
||||
/// Symlinks are unlinked, never followed and never zeroed: following one would
|
||||
/// destroy a target outside the tree.
|
||||
///
|
||||
/// Returns the number of files removed. The tree is removed even when
|
||||
/// individual steps fail, because leaving a half-wiped directory in place is
|
||||
/// the worst outcome available.
|
||||
pub fn secure_remove_dir_all(root: &Path, zero: bool) -> std::io::Result<u64> {
|
||||
let mut removed = 0u64;
|
||||
if !root.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
remove_tree(root, zero, &mut removed);
|
||||
|
||||
// Unconditional backstop: a walk that failed part-way must still not leave
|
||||
// the directory behind.
|
||||
match fs::remove_dir_all(root) {
|
||||
Ok(()) => Ok(removed),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(removed),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_tree(dir: &Path, zero: bool, removed: &mut u64) {
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
log::warn!("Secure erase could not read {}: {e}", dir.display());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
// file_type() on the DirEntry is lstat-based, so a symlink reports as a
|
||||
// symlink rather than as whatever it points at.
|
||||
let file_type = match entry.file_type() {
|
||||
Ok(ft) => ft,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if file_type.is_symlink() {
|
||||
let _ = fs::remove_file(&path);
|
||||
*removed += 1;
|
||||
} else if file_type.is_dir() {
|
||||
remove_tree(&path, zero, removed);
|
||||
let _ = fs::remove_dir(&path);
|
||||
} else {
|
||||
let should_zero = zero
|
||||
&& fs::symlink_metadata(&path)
|
||||
.map(|m| is_last_link(&m))
|
||||
.unwrap_or(false);
|
||||
let outcome = if should_zero {
|
||||
secure_remove_file(&path)
|
||||
} else {
|
||||
fs::remove_file(&path)
|
||||
};
|
||||
if outcome.is_ok() {
|
||||
*removed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn zeroing_erase_removes_a_nested_tree() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let nested = tmp.path().join("Default/Network");
|
||||
fs::create_dir_all(&nested).unwrap();
|
||||
let cookies = nested.join("Cookies");
|
||||
fs::write(&cookies, b"session=supersecretvalue").unwrap();
|
||||
|
||||
let removed = secure_remove_dir_all(tmp.path(), true).unwrap();
|
||||
assert!(
|
||||
removed >= 1,
|
||||
"expected at least the cookie file to be counted"
|
||||
);
|
||||
assert!(!tmp.path().exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn erase_without_zeroing_still_removes_everything() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::write(tmp.path().join("a"), b"x").unwrap();
|
||||
fs::create_dir_all(tmp.path().join("d")).unwrap();
|
||||
fs::write(tmp.path().join("d/b"), b"y").unwrap();
|
||||
|
||||
secure_remove_dir_all(tmp.path(), false).unwrap();
|
||||
assert!(!tmp.path().exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_root_is_not_an_error() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let absent = tmp.path().join("never-existed");
|
||||
assert_eq!(secure_remove_dir_all(&absent, true).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_symlink_is_unlinked_without_touching_its_target() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
// The target lives OUTSIDE the tree being erased. Following the link would
|
||||
// destroy a user's real file, which is the failure this guards against.
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let target = outside.path().join("precious");
|
||||
fs::write(&target, b"must survive intact").unwrap();
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
symlink(&target, tmp.path().join("link")).unwrap();
|
||||
|
||||
secure_remove_dir_all(tmp.path(), true).unwrap();
|
||||
|
||||
assert!(!tmp.path().exists());
|
||||
assert_eq!(fs::read(&target).unwrap(), b"must survive intact");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_second_hard_link_is_not_zeroed_through() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let inside = tmp.path().join("shared");
|
||||
fs::write(&inside, b"still referenced elsewhere").unwrap();
|
||||
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let other = outside.path().join("other-name");
|
||||
fs::hard_link(&inside, &other).unwrap();
|
||||
|
||||
secure_remove_dir_all(tmp.path(), true).unwrap();
|
||||
|
||||
// The link inside the tree is gone, and the surviving link still holds the
|
||||
// original bytes rather than a run of zeros.
|
||||
assert!(!tmp.path().exists());
|
||||
assert_eq!(fs::read(&other).unwrap(), b"still referenced elsewhere");
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,7 @@ mod ephemeral_dirs;
|
||||
mod extension_manager;
|
||||
mod extraction;
|
||||
mod fingerprint_consistency;
|
||||
mod fs_secure;
|
||||
mod geoip_downloader;
|
||||
mod geolocation;
|
||||
mod group_manager;
|
||||
|
||||
@@ -495,6 +495,18 @@ impl ProfileManager {
|
||||
// so nothing else would ever clean them up.
|
||||
crate::launch_gate_prefs::forget_profile(profile_id);
|
||||
|
||||
// Deleting the profile never touched its ephemeral directory, so a
|
||||
// decrypted or in-memory copy outlived the profile it belonged to with
|
||||
// nothing left that knew to reap it. The running-browser guard above only
|
||||
// rejects a live process_id, and the keep-decrypted path deliberately
|
||||
// clears process_id while leaving the plaintext tree populated. No-ops
|
||||
// when the profile has no ephemeral directory.
|
||||
crate::ephemeral_dirs::remove_ephemeral_dir(profile_id);
|
||||
|
||||
// Per-domain traffic history lives outside the profile directory, so it
|
||||
// survives the delete otherwise. It is already zero-overwritten on removal.
|
||||
crate::traffic_stats::delete_traffic_stats(profile_id);
|
||||
|
||||
// Remember sync mode before deleting local files
|
||||
let was_sync_enabled = profile.is_sync_enabled();
|
||||
|
||||
@@ -1558,7 +1570,11 @@ impl ProfileManager {
|
||||
None => {
|
||||
// No running instance found, clear process ID if set
|
||||
if profile.ephemeral {
|
||||
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
|
||||
let id = profile.id.to_string();
|
||||
crate::ephemeral_dirs::remove_ephemeral_dir(&id);
|
||||
// Destination history is kept outside the profile dir, so erasing
|
||||
// the profile alone still left the session's domains on disk.
|
||||
crate::traffic_stats::delete_traffic_stats(&id);
|
||||
}
|
||||
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
|
||||
@@ -530,38 +530,10 @@ pub fn delete_traffic_stats(id: &str) -> bool {
|
||||
removed
|
||||
}
|
||||
|
||||
/// Best-effort secure erase: overwrite the file's bytes with zeros and flush
|
||||
/// before unlinking, so the traffic history isn't trivially recoverable from
|
||||
/// the freed blocks. On copy-on-write / SSD storage the OS may still retain
|
||||
/// old blocks — this is a best-effort mitigation, not a guarantee.
|
||||
fn secure_remove_file(path: &std::path::Path) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
if let Ok(meta) = fs::metadata(path) {
|
||||
let len = meta.len();
|
||||
if len > 0 {
|
||||
if let Ok(mut f) = fs::OpenOptions::new().write(true).open(path) {
|
||||
let zeros = vec![0u8; 8192];
|
||||
let mut remaining = len;
|
||||
// The overwrite is best-effort and must never gate the unlink: a write
|
||||
// failure part-way (ENOSPC on a copy-on-write volume, EIO) would
|
||||
// otherwise leave the file both un-wiped and un-deleted, which is
|
||||
// strictly worse than the plain remove this replaced — and the caller
|
||||
// reports success either way, so the history would silently survive a
|
||||
// clear.
|
||||
while remaining > 0 {
|
||||
let chunk = remaining.min(zeros.len() as u64) as usize;
|
||||
if f.write_all(&zeros[..chunk]).is_err() {
|
||||
break;
|
||||
}
|
||||
remaining -= chunk as u64;
|
||||
}
|
||||
let _ = f.flush();
|
||||
let _ = f.sync_all();
|
||||
}
|
||||
}
|
||||
}
|
||||
fs::remove_file(path)
|
||||
}
|
||||
/// Best-effort secure erase. Shared with the ephemeral-profile teardown, which
|
||||
/// needs exactly the same "zero then unlink, never let the overwrite gate the
|
||||
/// unlink" behaviour; see `crate::fs_secure` for the caveats.
|
||||
use crate::fs_secure::secure_remove_file;
|
||||
|
||||
/// Clear all traffic stats (used when clearing cache), securely erasing each
|
||||
/// file first.
|
||||
|
||||
Reference in New Issue
Block a user