use std::{
fmt, fs, io,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
use crate::DowngradePolicy;
pub(crate) const LAST_VERSION_FILE: &str = "Last Version";
const MOVED_ASIDE_SUFFIX: &str = ".tauri-delete";
#[cfg(unix)]
const SINGLETON_LOCK_FILE: &str = "SingletonLock";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct ChromiumVersion {
pub major: u32,
pub minor: u32,
pub build: u32,
pub patch: u32,
}
impl ChromiumVersion {
pub(crate) fn embedded() -> Self {
Self {
major: cef::sys::CHROME_VERSION_MAJOR as u32,
minor: cef::sys::CHROME_VERSION_MINOR as u32,
build: cef::sys::CHROME_VERSION_BUILD as u32,
patch: cef::sys::CHROME_VERSION_PATCH as u32,
}
}
pub(crate) fn parse(text: &str) -> Option<Self> {
let mut components = text.trim().split('.').map(|c| c.parse::<u32>().ok());
let major = components.next()??;
let mut rest = [0u32; 3];
for slot in &mut rest {
match components.next() {
Some(component) => *slot = component?,
None => break,
}
}
if components.next().is_some() {
return None;
}
Some(Self {
major,
minor: rest[0],
build: rest[1],
patch: rest[2],
})
}
}
impl fmt::Display for ChromiumVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}.{}.{}.{}",
self.major, self.minor, self.build, self.patch
)
}
}
#[derive(Debug)]
pub(crate) enum Outcome {
Unknown,
Kept,
MinorDowngrade { last: ChromiumVersion },
KeptNewer { last: ChromiumVersion },
Reset { last: ChromiumVersion },
InUse {
last: ChromiumVersion,
holder: String,
},
ResetFailed {
last: ChromiumVersion,
error: io::Error,
},
}
pub(crate) fn prepare_root_cache_path(root: &Path, policy: DowngradePolicy) -> Outcome {
prepare(root, ChromiumVersion::embedded(), policy)
}
fn prepare(root: &Path, current: ChromiumVersion, policy: DowngradePolicy) -> Outcome {
delete_in_background(moved_aside_siblings(root));
let outcome = match read_last_version(root) {
None => Outcome::Unknown,
Some(last) if last.major < current.major || last <= current => Outcome::Kept,
Some(last) if last.major == current.major => Outcome::MinorDowngrade { last },
Some(last) => match policy {
DowngradePolicy::KeepProfile => Outcome::KeptNewer { last },
DowngradePolicy::ResetProfile => match singleton_holder(root) {
Some(holder) => Outcome::InUse { last, holder },
None => match move_aside(root) {
Ok(moved) => {
let _ = fs::create_dir_all(root);
delete_in_background(vec![moved]);
Outcome::Reset { last }
}
Err(error) => Outcome::ResetFailed { last, error },
},
},
},
};
report(&outcome, current);
if !matches!(outcome, Outcome::InUse { .. } | Outcome::ResetFailed { .. })
&& let Err(error) = write_last_version(root, current)
{
log::warn!(
"failed to record the Chromium version in {}: {error}. A rollback to an older CEF will not be detected on the next launch.",
root.join(LAST_VERSION_FILE).display()
);
}
outcome
}
fn report(outcome: &Outcome, current: ChromiumVersion) {
match outcome {
Outcome::Unknown | Outcome::Kept => {}
Outcome::MinorDowngrade { last } => log::info!(
"the CEF profile was last used by Chromium {last}, a later build of the milestone this Chromium {current} belongs to; keeping it, as Chrome does"
),
Outcome::KeptNewer { last } => log::warn!(
"the CEF profile was last used by Chromium {last}, a newer milestone than this Chromium {current}; keeping it, as Chrome does. Chromium never migrates a profile backwards, so a store whose format changed in between may go unused or be discarded. Set DowngradePolicy::ResetProfile to start on an empty profile instead."
),
Outcome::Reset { last } => log::warn!(
"the CEF profile was last used by Chromium {last}, a newer milestone than this Chromium {current}; it was moved aside and Chromium starts on an empty one, as DowngradePolicy::ResetProfile asks. Cookies, local storage, IndexedDB and granted permissions are gone."
),
Outcome::InUse { last, holder } => log::warn!(
"the CEF profile was last used by Chromium {last}, a newer milestone than this Chromium {current}, but {holder} holds it, so it is left in place"
),
Outcome::ResetFailed { last, error } => log::warn!(
"the CEF profile was last used by Chromium {last}, a newer milestone than this Chromium {current}, and moving it aside failed: {error}. Chromium opens it anyway; the reset is retried on the next launch."
),
}
}
fn read_last_version(root: &Path) -> Option<ChromiumVersion> {
fs::read_to_string(root.join(LAST_VERSION_FILE))
.ok()
.and_then(|text| ChromiumVersion::parse(&text))
}
fn write_last_version(root: &Path, version: ChromiumVersion) -> io::Result<()> {
fs::write(root.join(LAST_VERSION_FILE), version.to_string())
}
fn move_aside(root: &Path) -> io::Result<PathBuf> {
let (Some(parent), Some(name)) = (root.parent(), root.file_name()) else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"the root cache path has no parent directory to move it within",
));
};
let mut stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.as_millis())
.unwrap_or_default();
loop {
let mut target_name = name.to_os_string();
target_name.push(format!(".{stamp}{MOVED_ASIDE_SUFFIX}"));
let target = parent.join(target_name);
if !target.exists() {
fs::rename(root, &target)?;
return Ok(target);
}
stamp += 1;
}
}
fn moved_aside_siblings(root: &Path) -> Vec<PathBuf> {
let (Some(parent), Some(name)) = (root.parent(), root.file_name().and_then(|n| n.to_str()))
else {
return Vec::new();
};
let prefix = format!("{name}.");
let Ok(entries) = fs::read_dir(parent) else {
return Vec::new();
};
entries
.flatten()
.filter(|entry| {
let file_name = entry.file_name();
let Some(file_name) = file_name.to_str() else {
return false;
};
file_name.starts_with(&prefix)
&& file_name.ends_with(MOVED_ASIDE_SUFFIX)
&& entry.path().is_dir()
})
.map(|entry| entry.path())
.collect()
}
fn delete_in_background(paths: Vec<PathBuf>) {
if paths.is_empty() {
return;
}
let spawned = std::thread::Builder::new()
.name("tauri-cef-profile-cleanup".into())
.spawn(move || delete_moved_aside(&paths));
if let Err(error) = spawned {
log::warn!("failed to spawn the CEF profile cleanup thread: {error}");
}
}
fn delete_moved_aside(paths: &[PathBuf]) {
for path in paths {
if let Err(error) = fs::remove_dir_all(path) {
log::warn!(
"failed to delete the CEF profile moved aside at {}: {error}",
path.display()
);
}
}
}
#[cfg(unix)]
fn singleton_holder(root: &Path) -> Option<String> {
let target = fs::read_link(root.join(SINGLETON_LOCK_FILE)).ok()?;
let target = target.to_string_lossy();
let (host, pid) = target.rsplit_once('-')?;
let pid: i32 = pid.parse().ok()?;
if hostname().as_deref() != Some(host) {
return Some(format!("process {pid} on {host}"));
}
let alive = unsafe { libc::kill(pid, 0) } == 0
|| io::Error::last_os_error().raw_os_error() == Some(libc::EPERM);
alive.then(|| format!("process {pid}"))
}
#[cfg(not(unix))]
fn singleton_holder(_root: &Path) -> Option<String> {
None
}
#[cfg(unix)]
fn hostname() -> Option<String> {
let mut buffer = [0u8; 256];
let status = unsafe { libc::gethostname(buffer.as_mut_ptr().cast(), buffer.len()) };
if status != 0 {
return None;
}
let end = buffer
.iter()
.position(|&byte| byte == 0)
.unwrap_or(buffer.len());
Some(String::from_utf8_lossy(&buffer[..end]).into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn v(text: &str) -> ChromiumVersion {
ChromiumVersion::parse(text).unwrap()
}
fn temp_root() -> PathBuf {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let dir = std::env::temp_dir().join(format!(
"tauri-cef-downgrade-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::SeqCst)
));
let _ = fs::remove_dir_all(&dir);
let root = dir.join("cef");
fs::create_dir_all(&root).unwrap();
root
}
fn stamp(root: &Path, version: &str) {
write_last_version(root, v(version)).unwrap();
}
fn recorded(root: &Path) -> Option<String> {
read_last_version(root).map(|version| version.to_string())
}
fn with_site_data(root: &Path) -> PathBuf {
let cookies = root.join("Default").join("Cookies");
fs::create_dir_all(cookies.parent().unwrap()).unwrap();
fs::write(&cookies, b"jar").unwrap();
cookies
}
#[test]
fn parses_and_orders_chromium_versions() {
assert_eq!(v("152.0.7977.83").to_string(), "152.0.7977.83");
assert_eq!(v(" 151.0.7922.174\n").to_string(), "151.0.7922.174");
assert_eq!(
v("152").to_string(),
"152.0.0.0",
"missing components are zero"
);
assert!(v("152.0.7977.83") > v("151.0.7922.174"));
assert!(v("152.0.7977.83") > v("152.0.7977.50"));
assert!(v("152.1.0.0") > v("152.0.9999.9999"));
for invalid in ["", "abc", "152.x", "1.2.3.4.5", "-1"] {
assert!(ChromiumVersion::parse(invalid).is_none(), "{invalid:?}");
}
}
#[test]
fn the_embedded_version_is_the_cef_crates() {
assert_eq!(
ChromiumVersion::embedded().to_string(),
format!(
"{}.{}.{}.{}",
cef::sys::CHROME_VERSION_MAJOR,
cef::sys::CHROME_VERSION_MINOR,
cef::sys::CHROME_VERSION_BUILD,
cef::sys::CHROME_VERSION_PATCH
)
);
}
#[test]
fn a_profile_without_a_breadcrumb_only_gets_one() {
let root = temp_root();
let cookies = with_site_data(&root);
let outcome = prepare(&root, v("152.0.7977.83"), DowngradePolicy::ResetProfile);
assert!(matches!(outcome, Outcome::Unknown), "{outcome:?}");
assert!(
cookies.exists(),
"a profile of unknown origin is never reset"
);
assert_eq!(recorded(&root).as_deref(), Some("152.0.7977.83"));
}
#[test]
fn an_unreadable_breadcrumb_counts_as_none() {
let root = temp_root();
fs::write(root.join(LAST_VERSION_FILE), "not a version").unwrap();
let outcome = prepare(&root, v("151.0.7922.174"), DowngradePolicy::ResetProfile);
assert!(matches!(outcome, Outcome::Unknown), "{outcome:?}");
assert_eq!(recorded(&root).as_deref(), Some("151.0.7922.174"));
}
#[test]
fn an_upgrade_keeps_the_profile_and_moves_the_breadcrumb_forward() {
let root = temp_root();
stamp(&root, "151.0.7922.174");
let cookies = with_site_data(&root);
let outcome = prepare(&root, v("152.0.7977.83"), DowngradePolicy::ResetProfile);
assert!(matches!(outcome, Outcome::Kept), "{outcome:?}");
assert!(cookies.exists());
assert_eq!(recorded(&root).as_deref(), Some("152.0.7977.83"));
}
#[test]
fn the_same_version_keeps_the_profile() {
let root = temp_root();
stamp(&root, "152.0.7977.83");
let cookies = with_site_data(&root);
let outcome = prepare(&root, v("152.0.7977.83"), DowngradePolicy::ResetProfile);
assert!(matches!(outcome, Outcome::Kept), "{outcome:?}");
assert!(cookies.exists());
}
#[test]
fn a_downgrade_within_the_milestone_keeps_the_profile() {
let root = temp_root();
stamp(&root, "152.0.7977.90");
let cookies = with_site_data(&root);
let outcome = prepare(&root, v("152.0.7977.83"), DowngradePolicy::ResetProfile);
assert!(
matches!(outcome, Outcome::MinorDowngrade { .. }),
"{outcome:?}"
);
assert!(cookies.exists());
assert_eq!(
recorded(&root).as_deref(),
Some("152.0.7977.83"),
"the breadcrumb always names the version that ran last"
);
}
#[test]
fn keep_profile_keeps_a_newer_milestones_profile() {
let root = temp_root();
stamp(&root, "152.0.7977.83");
let cookies = with_site_data(&root);
let outcome = prepare(&root, v("151.0.7922.174"), DowngradePolicy::KeepProfile);
assert!(matches!(outcome, Outcome::KeptNewer { .. }), "{outcome:?}");
assert!(cookies.exists());
assert_eq!(
recorded(&root).as_deref(),
Some("151.0.7922.174"),
"the older Chromium is now the last one to have run on the profile"
);
}
#[test]
fn reset_profile_moves_a_newer_milestones_profile_aside() {
let root = temp_root();
stamp(&root, "152.0.7977.83");
let cookies = with_site_data(&root);
let outcome = prepare(&root, v("151.0.7922.174"), DowngradePolicy::ResetProfile);
assert!(matches!(outcome, Outcome::Reset { .. }), "{outcome:?}");
assert!(
root.is_dir(),
"the root is recreated for Chromium to start on"
);
assert!(
!cookies.exists(),
"nothing of the newer profile is left in it"
);
assert_eq!(recorded(&root).as_deref(), Some("151.0.7922.174"));
}
#[test]
fn moving_aside_stays_next_to_the_root_and_is_found_again() {
let root = temp_root();
let cookies = with_site_data(&root);
let moved = move_aside(&root).unwrap();
assert_eq!(moved.parent(), root.parent());
assert!(
moved.join("Default").join("Cookies").exists(),
"the contents travel with the directory"
);
assert!(!cookies.exists());
assert_eq!(moved_aside_siblings(&root), vec![moved.clone()]);
delete_moved_aside(&moved_aside_siblings(&root));
assert!(!moved.exists());
assert!(moved_aside_siblings(&root).is_empty());
}
#[test]
fn unrelated_siblings_are_not_cleanup_candidates() {
let root = temp_root();
let parent = root.parent().unwrap();
fs::create_dir_all(parent.join("cef-backup")).unwrap();
fs::create_dir_all(parent.join("other.tauri-delete")).unwrap();
fs::write(
parent.join("cef.1.tauri-delete"),
b"a file, not a directory",
)
.unwrap();
assert!(moved_aside_siblings(&root).is_empty());
}
#[test]
fn a_root_without_a_parent_cannot_be_reset() {
let root = PathBuf::from("/");
assert!(move_aside(&root).is_err());
assert!(moved_aside_siblings(&root).is_empty());
}
#[cfg(unix)]
fn lock(root: &Path, target: &str) {
std::os::unix::fs::symlink(target, root.join(SINGLETON_LOCK_FILE)).unwrap();
}
#[cfg(unix)]
#[test]
fn a_live_singleton_holder_blocks_the_reset_and_keeps_it_pending() {
let root = temp_root();
stamp(&root, "152.0.7977.83");
let cookies = with_site_data(&root);
lock(
&root,
&format!("{}-{}", hostname().unwrap(), std::process::id()),
);
let outcome = prepare(&root, v("151.0.7922.174"), DowngradePolicy::ResetProfile);
assert!(matches!(outcome, Outcome::InUse { .. }), "{outcome:?}");
assert!(cookies.exists());
assert_eq!(
recorded(&root).as_deref(),
Some("152.0.7977.83"),
"the breadcrumb is left to the instance holding the profile, so the reset is retried"
);
}
#[cfg(unix)]
#[test]
fn a_lock_from_another_host_blocks_the_reset() {
let root = temp_root();
stamp(&root, "152.0.7977.83");
lock(&root, "some-other-host.example-1");
let outcome = prepare(&root, v("151.0.7922.174"), DowngradePolicy::ResetProfile);
assert!(matches!(outcome, Outcome::InUse { .. }), "{outcome:?}");
}
#[cfg(unix)]
#[test]
fn a_stale_singleton_lock_does_not_block_the_reset() {
let root = temp_root();
stamp(&root, "152.0.7977.83");
let cookies = with_site_data(&root);
let mut child = std::process::Command::new("true").spawn().unwrap();
child.wait().unwrap();
lock(&root, &format!("{}-{}", hostname().unwrap(), child.id()));
let outcome = prepare(&root, v("151.0.7922.174"), DowngradePolicy::ResetProfile);
assert!(matches!(outcome, Outcome::Reset { .. }), "{outcome:?}");
assert!(!cookies.exists());
}
#[cfg(unix)]
#[test]
fn a_malformed_lock_does_not_block_the_reset() {
let root = temp_root();
stamp(&root, "152.0.7977.83");
lock(&root, "no-pid-here-");
let outcome = prepare(&root, v("151.0.7922.174"), DowngradePolicy::ResetProfile);
assert!(matches!(outcome, Outcome::Reset { .. }), "{outcome:?}");
}
}