refactor: improve ephemeral ux

This commit is contained in:
zhom
2026-08-16 17:22:43 +04:00
parent 1a36fb9c12
commit a0175eab0d
17 changed files with 667 additions and 110 deletions
+6 -1
View File
@@ -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
View File
@@ -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());
}
}
+213
View File
@@ -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");
}
}
+1
View File
@@ -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;
+17 -1
View File
@@ -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();
+4 -32
View File
@@ -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.
+83 -29
View File
@@ -29,6 +29,7 @@ import {
LuSettings,
LuShield,
LuShieldCheck,
LuTimer,
LuTrash2,
LuUpload,
LuUsers,
@@ -735,6 +736,13 @@ function ProfileInfoLayout({
[visibleActions],
);
// An ephemeral profile is discarded when the browser closes, so it has
// nowhere to keep cookies, extensions or a synced copy. The sections were
// hidden outright, which left no way to discover that and read as the app
// being broken or the plan lacking the feature. Keep them listed and explain.
const isEphemeral = profile.ephemeral === true;
const isWayfernProfile = profile.browser === "wayfern";
const deleteAction = findAction("delete");
const fingerprintAction = findAction("fingerprint");
const cookiesManageAction = findAction("cookiesManage");
@@ -814,20 +822,20 @@ function ProfileInfoLayout({
cookieCount !== null && cookieCount > 0
? cookieCount.toLocaleString()
: undefined,
hidden: !cookiesAction,
hidden: !cookiesAction && !(isEphemeral && isWayfernProfile),
},
{
id: "extensions",
icon: <LuPuzzle className="size-3.5" />,
label: t("profileInfo.sections.extensions"),
badge: extensionGroupName ?? undefined,
hidden: !extensionAction,
hidden: !extensionAction && !isEphemeral,
},
{
id: "sync",
icon: <LuRefreshCw className="size-3.5" />,
label: t("profileInfo.sections.sync"),
hidden: !syncAction,
hidden: !syncAction && !isEphemeral,
},
{
id: "automation",
@@ -1073,34 +1081,55 @@ function ProfileInfoLayout({
/>
)}
{section === "cookies" && (
<CookiesSectionInline
profile={profile}
isRunning={isRunning}
isDisabled={isDisabled}
onCopyCookies={cookiesCopyAction?.onClick}
onImportCookies={cookiesManageAction?.onClick}
t={t}
/>
)}
{section === "cookies" &&
(isEphemeral ? (
<EphemeralSectionNotice
title={t("profileInfo.sections.cookies")}
description={t("profileInfo.ephemeral.cookiesUnavailable")}
t={t}
/>
) : (
<CookiesSectionInline
profile={profile}
isRunning={isRunning}
isDisabled={isDisabled}
onCopyCookies={cookiesCopyAction?.onClick}
onImportCookies={cookiesManageAction?.onClick}
t={t}
/>
))}
{section === "extensions" && (
<ExtensionsSectionInline
profile={profile}
isDisabled={isDisabled}
t={t}
/>
)}
{section === "extensions" &&
(isEphemeral ? (
<EphemeralSectionNotice
title={t("profileInfo.sections.extensions")}
description={t("profileInfo.ephemeral.extensionsUnavailable")}
t={t}
/>
) : (
<ExtensionsSectionInline
profile={profile}
isDisabled={isDisabled}
t={t}
/>
))}
{section === "sync" && (
<SyncSectionInline
profile={profile}
syncMode={syncMode}
syncStatus={syncStatus}
isDisabled={isDisabled}
t={t}
/>
)}
{section === "sync" &&
(isEphemeral ? (
<EphemeralSectionNotice
title={t("profileInfo.sections.sync")}
description={t("profileInfo.ephemeral.syncUnavailable")}
t={t}
/>
) : (
<SyncSectionInline
profile={profile}
syncMode={syncMode}
syncStatus={syncStatus}
isDisabled={isDisabled}
t={t}
/>
))}
{section === "automation" && (
<LaunchHookEditor profile={profile} t={t} />
@@ -1507,6 +1536,31 @@ function NetworkSectionInline({
);
}
/// Explains why a section has nothing to offer on an ephemeral profile.
/// Mirrors the locked-fingerprint empty state so the two read as one pattern.
function EphemeralSectionNotice({
title,
description,
t,
}: {
title: string;
description: string;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
return (
<div className="flex flex-col items-center gap-3 rounded-lg border p-6 text-center">
<LuTimer className="size-4 shrink-0 text-muted-foreground" />
<h3 className="text-sm font-medium text-foreground">{title}</h3>
<p className="max-w-[48ch] text-sm text-pretty text-muted-foreground">
{description}
</p>
<p className="max-w-[48ch] text-xs text-pretty text-muted-foreground">
{t("profileInfo.ephemeral.hint")}
</p>
</div>
);
}
function ExtensionsSectionInline({
profile,
isDisabled,
+7 -1
View File
@@ -1230,6 +1230,12 @@
"syncing": "Syncing",
"synced": "Synced",
"error": "Error"
},
"ephemeral": {
"cookiesUnavailable": "Ephemeral profiles are discarded when the browser closes, so there are no cookies to manage here.",
"extensionsUnavailable": "Ephemeral profiles are discarded when the browser closes, so extension groups cannot be assigned to them.",
"syncUnavailable": "Ephemeral profiles are discarded when the browser closes, so there is nothing to sync to the cloud.",
"hint": "Create a regular profile if you need this to persist."
}
},
"extensions": {
@@ -2203,7 +2209,7 @@
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot warms your profiles overnight on a remote machine, so they keep their cookies and their history without your computer being on. It needs a paid plan."
"hint": "Cookie Bot warms your profiles overnight on a remote machine, so they keep their cookies and their history without your computer being on."
},
"empty": {
"title": "No profiles are enrolled",
+7 -1
View File
@@ -1233,6 +1233,12 @@
"syncing": "Sincronizando",
"synced": "Sincronizado",
"error": "Error"
},
"ephemeral": {
"cookiesUnavailable": "Los perfiles efímeros se descartan al cerrar el navegador, así que aquí no hay cookies que gestionar.",
"extensionsUnavailable": "Los perfiles efímeros se descartan al cerrar el navegador, así que no se les pueden asignar grupos de extensiones.",
"syncUnavailable": "Los perfiles efímeros se descartan al cerrar el navegador, así que no hay nada que sincronizar con la nube.",
"hint": "Crea un perfil normal si necesitas que esto se conserve."
}
},
"extensions": {
@@ -2210,7 +2216,7 @@
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot calienta tus perfiles por la noche en una máquina remota, así conservan sus cookies y su historial sin que tu ordenador esté encendido. Requiere un plan de pago."
"hint": "Cookie Bot calienta tus perfiles por la noche en una máquina remota, así conservan sus cookies y su historial sin que tu ordenador esté encendido."
},
"empty": {
"title": "No hay perfiles inscritos",
+7 -1
View File
@@ -1233,6 +1233,12 @@
"syncing": "Synchronisation",
"synced": "Synchronisé",
"error": "Erreur"
},
"ephemeral": {
"cookiesUnavailable": "Les profils éphémères sont supprimés à la fermeture du navigateur : il n'y a donc aucun cookie à gérer ici.",
"extensionsUnavailable": "Les profils éphémères sont supprimés à la fermeture du navigateur : aucun groupe d'extensions ne peut leur être attribué.",
"syncUnavailable": "Les profils éphémères sont supprimés à la fermeture du navigateur : il n'y a rien à synchroniser vers le cloud.",
"hint": "Créez un profil normal si vous avez besoin de conserver ces données."
}
},
"extensions": {
@@ -2210,7 +2216,7 @@
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot chauffe vos profils la nuit sur une machine distante : ils conservent leurs cookies et leur historique sans que votre ordinateur soit allumé. Nécessite un forfait payant."
"hint": "Cookie Bot chauffe vos profils la nuit sur une machine distante : ils conservent leurs cookies et leur historique sans que votre ordinateur soit allumé."
},
"empty": {
"title": "Aucun profil inscrit",
+7 -1
View File
@@ -1230,6 +1230,12 @@
"syncing": "同期中",
"synced": "同期済み",
"error": "エラー"
},
"ephemeral": {
"cookiesUnavailable": "一時プロファイルはブラウザーを閉じると破棄されるため、ここで管理できる Cookie はありません。",
"extensionsUnavailable": "一時プロファイルはブラウザーを閉じると破棄されるため、拡張機能グループを割り当てられません。",
"syncUnavailable": "一時プロファイルはブラウザーを閉じると破棄されるため、クラウドに同期するものはありません。",
"hint": "データを保持したい場合は通常のプロファイルを作成してください。"
}
},
"extensions": {
@@ -2203,7 +2209,7 @@
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot はリモートマシンで夜間にプロファイルをウォームアップするため、お使いのコンピューターを起動していなくても Cookie と履歴が維持されます。有料プランが必要です。"
"hint": "Cookie Bot はリモートマシンで夜間にプロファイルをウォームアップするため、お使いのコンピューターを起動していなくても Cookie と履歴が維持されます。"
},
"empty": {
"title": "登録されたプロファイルはありません",
+7 -1
View File
@@ -1230,6 +1230,12 @@
"syncing": "동기화 중",
"synced": "동기화됨",
"error": "오류"
},
"ephemeral": {
"cookiesUnavailable": "임시 프로필은 브라우저를 닫으면 삭제되므로 여기에서 관리할 쿠키가 없습니다.",
"extensionsUnavailable": "임시 프로필은 브라우저를 닫으면 삭제되므로 확장 프로그램 그룹을 지정할 수 없습니다.",
"syncUnavailable": "임시 프로필은 브라우저를 닫으면 삭제되므로 클라우드에 동기화할 항목이 없습니다.",
"hint": "데이터를 유지하려면 일반 프로필을 만드세요."
}
},
"extensions": {
@@ -2203,7 +2209,7 @@
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot은 원격 머신에서 밤새 프로필을 예열해, 내 컴퓨터를 켜 두지 않아도 쿠키와 방문 기록이 유지됩니다. 유료 요금제가 필요합니다."
"hint": "Cookie Bot은 원격 머신에서 밤새 프로필을 예열해, 내 컴퓨터를 켜 두지 않아도 쿠키와 방문 기록이 유지됩니다."
},
"empty": {
"title": "등록된 프로필이 없습니다",
+7 -1
View File
@@ -1233,6 +1233,12 @@
"syncing": "Sincronizando",
"synced": "Sincronizado",
"error": "Erro"
},
"ephemeral": {
"cookiesUnavailable": "Perfis efêmeros são descartados quando o navegador fecha, portanto não há cookies para gerenciar aqui.",
"extensionsUnavailable": "Perfis efêmeros são descartados quando o navegador fecha, portanto não é possível atribuir grupos de extensões a eles.",
"syncUnavailable": "Perfis efêmeros são descartados quando o navegador fecha, portanto não há nada para sincronizar com a nuvem.",
"hint": "Crie um perfil normal se precisar que isso seja mantido."
}
},
"extensions": {
@@ -2210,7 +2216,7 @@
},
"locked": {
"title": "Cookie Bot",
"hint": "O Cookie Bot aquece seus perfis durante a noite em uma máquina remota, para que mantenham os cookies e o histórico sem o seu computador ligado. Requer um plano pago."
"hint": "O Cookie Bot aquece seus perfis durante a noite em uma máquina remota, para que mantenham os cookies e o histórico sem o seu computador ligado."
},
"empty": {
"title": "Nenhum perfil inscrito",
+7 -1
View File
@@ -1236,6 +1236,12 @@
"syncing": "Синхронизация",
"synced": "Синхронизировано",
"error": "Ошибка"
},
"ephemeral": {
"cookiesUnavailable": "Временные профили удаляются при закрытии браузера, поэтому здесь нет cookies для управления.",
"extensionsUnavailable": "Временные профили удаляются при закрытии браузера, поэтому назначить им группы расширений нельзя.",
"syncUnavailable": "Временные профили удаляются при закрытии браузера, поэтому синхронизировать с облаком нечего.",
"hint": "Создайте обычный профиль, если эти данные должны сохраняться."
}
},
"extensions": {
@@ -2217,7 +2223,7 @@
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot прогревает ваши профили ночью на удалённой машине, чтобы они сохраняли cookies и историю, пока ваш компьютер выключен. Требуется платный тариф."
"hint": "Cookie Bot прогревает ваши профили ночью на удалённой машине, чтобы они сохраняли cookies и историю, пока ваш компьютер выключен."
},
"empty": {
"title": "Нет подключённых профилей",
+7 -1
View File
@@ -1230,6 +1230,12 @@
"syncing": "Eşitleniyor",
"synced": "Eşitlendi",
"error": "Hata"
},
"ephemeral": {
"cookiesUnavailable": "Geçici profiller tarayıcı kapandığında silinir, bu yüzden burada yönetilecek çerez yoktur.",
"extensionsUnavailable": "Geçici profiller tarayıcı kapandığında silinir, bu yüzden onlara uzantı grubu atanamaz.",
"syncUnavailable": "Geçici profiller tarayıcı kapandığında silinir, bu yüzden buluta eşitlenecek bir şey yoktur.",
"hint": "Bunun kalıcı olmasını istiyorsanız normal bir profil oluşturun."
}
},
"extensions": {
@@ -2203,7 +2209,7 @@
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot, profillerinizi gece boyunca uzak bir makinede ısıtır; böylece bilgisayarınız açık olmadan çerezlerini ve geçmişlerini korurlar. Ücretli bir plan gerekir."
"hint": "Cookie Bot, profillerinizi gece boyunca uzak bir makinede ısıtır; böylece bilgisayarınız açık olmadan çerezlerini ve geçmişlerini korurlar."
},
"empty": {
"title": "Kayıtlı profil yok",
+7 -1
View File
@@ -1230,6 +1230,12 @@
"syncing": "Đang đồng bộ",
"synced": "Đã đồng bộ",
"error": "Lỗi"
},
"ephemeral": {
"cookiesUnavailable": "Hồ sơ tạm thời bị xoá khi đóng trình duyệt, nên ở đây không có cookie nào để quản lý.",
"extensionsUnavailable": "Hồ sơ tạm thời bị xoá khi đóng trình duyệt, nên không thể gán nhóm tiện ích mở rộng cho chúng.",
"syncUnavailable": "Hồ sơ tạm thời bị xoá khi đóng trình duyệt, nên không có gì để đồng bộ lên đám mây.",
"hint": "Hãy tạo hồ sơ thường nếu bạn cần giữ lại dữ liệu này."
}
},
"extensions": {
@@ -2203,7 +2209,7 @@
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot làm ấm hồ sơ của bạn qua đêm trên máy từ xa, giúp chúng giữ được cookie và lịch sử mà không cần bật máy tính của bạn. Cần gói trả phí."
"hint": "Cookie Bot làm ấm hồ sơ của bạn qua đêm trên máy từ xa, giúp chúng giữ được cookie và lịch sử mà không cần bật máy tính của bạn."
},
"empty": {
"title": "Chưa có hồ sơ nào được đăng ký",
+7 -1
View File
@@ -1230,6 +1230,12 @@
"syncing": "同步中",
"synced": "已同步",
"error": "错误"
},
"ephemeral": {
"cookiesUnavailable": "临时配置在浏览器关闭时会被丢弃,因此这里没有可管理的 Cookie。",
"extensionsUnavailable": "临时配置在浏览器关闭时会被丢弃,因此无法为其分配扩展分组。",
"syncUnavailable": "临时配置在浏览器关闭时会被丢弃,因此没有可同步到云端的内容。",
"hint": "如果需要保留这些数据,请创建普通配置。"
}
},
"extensions": {
@@ -2203,7 +2209,7 @@
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot 在远程机器上通宵养号,无需开着你的电脑也能保住 Cookie 和历史记录。需要付费套餐。"
"hint": "Cookie Bot 在远程机器上通宵养号,无需开着你的电脑也能保住 Cookie 和历史记录。"
},
"empty": {
"title": "尚未加入任何配置文件",