refactor: cleanup

This commit is contained in:
zhom
2026-08-06 14:39:53 -07:00
parent 39bbdcb547
commit f12a84e18f
49 changed files with 4555 additions and 377 deletions
+1
View File
@@ -1821,6 +1821,7 @@ dependencies = [
"flate2",
"futures-util",
"globset",
"gtk",
"http-body-util",
"hyper",
"hyper-util",
+4
View File
@@ -116,6 +116,10 @@ sys-locale = "0.3"
[target.'cfg(unix)'.dependencies]
nix = { version = "0.31", features = ["signal", "process"] }
# Reading the desktop's titlebar button layout for the in-app window controls.
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18"
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
objc2 = "0.6.4"
+1
View File
@@ -10,6 +10,7 @@
"core:event:allow-emit-to",
"core:event:allow-unlisten",
"core:window:allow-start-dragging",
"core:window:allow-start-resize-dragging",
"core:window:allow-close",
"core:window:allow-is-maximized",
"core:window:allow-minimize",
+22 -9
View File
@@ -3033,7 +3033,7 @@ fn cookie_bot_eligible_profile(
.find(|p| p.id.to_string() == profile_id)
.ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?;
crate::cookie_bot::bot_precondition(&profile)
crate::cookie_bot::bot_precondition(&profile, &crate::cookie_bot::exit_reachability(&profile))
.map_err(|reason| (StatusCode::BAD_REQUEST, reason))?;
Ok(profile)
}
@@ -3315,10 +3315,7 @@ async fn list_cookie_bot_runs(
async fn start_cookie_bot_run(
Json(request): Json<StartCookieBotRunRequest>,
) -> Result<(StatusCode, Json<crate::cookie_bot::CookieBotRunStarted>), (StatusCode, String)> {
if !crate::cloud_auth::CLOUD_AUTH
.can_use_browser_automation()
.await
{
if !crate::cloud_auth::CLOUD_AUTH.can_use_cookie_bot().await {
return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
}
@@ -4234,14 +4231,22 @@ mod tests {
let mut local_only = profile_with(SyncMode::Disabled, Some("macos"));
local_only.proxy_id = Some("proxy-1".to_string());
assert!(
crate::cookie_bot::bot_precondition(&local_only).is_err(),
crate::cookie_bot::bot_precondition(
&local_only,
&crate::remote_exit::ExitReachability::Remote
)
.is_err(),
"a profile with no cloud copy has nothing for a host to open"
);
let mut encrypted = profile_with(SyncMode::Encrypted, Some("macos"));
encrypted.proxy_id = Some("proxy-1".to_string());
assert!(
crate::cookie_bot::bot_precondition(&encrypted).is_err(),
crate::cookie_bot::bot_precondition(
&encrypted,
&crate::remote_exit::ExitReachability::Remote
)
.is_err(),
"a host cannot decrypt a profile whose key never leaves this machine"
);
@@ -4249,13 +4254,21 @@ mod tests {
datacenter_egress.proxy_id = None;
datacenter_egress.vpn_id = None;
assert!(
crate::cookie_bot::bot_precondition(&datacenter_egress).is_err(),
crate::cookie_bot::bot_precondition(
&datacenter_egress,
&crate::remote_exit::ExitReachability::None
)
.is_err(),
"hours of traffic from a hosting ASN damages the identity being warmed"
);
let mut eligible = profile_with(SyncMode::Regular, Some("macos"));
eligible.proxy_id = Some("proxy-1".to_string());
assert!(crate::cookie_bot::bot_precondition(&eligible).is_ok());
assert!(crate::cookie_bot::bot_precondition(
&eligible,
&crate::remote_exit::ExitReachability::Remote
)
.is_ok());
}
#[test]
+15
View File
@@ -819,6 +819,21 @@ impl CloudAuthManager {
}
/// Launch/drive profiles programmatically (local API + MCP automation).
/// Whether this account may run the nightly Cookie Bot.
///
/// NOT `can_use_browser_automation`. Solo is exactly the plan where the two
/// disagree — it pays for a nightly bot and has `browser_automation: false` —
/// so gating the bot on automation refused a Solo customer the one feature
/// their plan is sold on, and answered 402 while their scheduled runs kept
/// working server-side.
pub async fn can_use_cookie_bot(&self) -> bool {
self
.entitlements()
.await
.map(|e| e.cookie_bot)
.unwrap_or(false)
}
pub async fn can_use_browser_automation(&self) -> bool {
#[cfg(feature = "e2e")]
if crate::e2e_automation_enabled()
+642 -11
View File
@@ -51,6 +51,20 @@ const REPORT_CODES: FailureCodes = FailureCodes {
conflict: cloud_errors::UNAVAILABLE,
};
/// Failure codes for the user-template routes.
///
/// Distinct from `SCHEDULE_CODES` on every axis that matters: a 404 here is a
/// template that was deleted (possibly from another device), not an unenrolled
/// profile, and a 409 is a name the user already used, not a teammate's
/// enrolment. Sharing the schedule set would have told someone renaming a site
/// list that a colleague already warms this profile.
const TEMPLATE_CODES: FailureCodes = FailureCodes {
bad_request: "COOKIE_BOT_INVALID_TEMPLATE_NAME",
forbidden: "COOKIE_BOT_NOT_ENTITLED",
not_found: "COOKIE_BOT_TEMPLATE_NOT_FOUND",
conflict: "COOKIE_BOT_TEMPLATE_NAME_TAKEN",
};
/// Every cookie-bot call fails as a code the frontend can translate.
///
/// There is no `Other(String)` carrying backend English: a raw message reaches
@@ -91,6 +105,18 @@ impl From<BackendFailure> for CookieBotError {
// One place for every request and response shape, so a backend contract change
// is a single edit here rather than a hunt through call sites.
/// One time-of-day an enrolment fires, on a set of local weekdays.
///
/// Copy, and deliberately tiny: a calendar is a list of these, and the desktop
/// rebuilds that list on every keystroke in the enrolment form.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotSlot {
/// Bitmask of local weekdays, bit 0 = Monday. At least one bit set.
pub days_mask: u8,
/// Minutes past local midnight, in the schedule's timezone.
pub run_at_minute: u16,
}
/// A profile enrolled in the nightly bot.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotSchedule {
@@ -98,13 +124,31 @@ pub struct CookieBotSchedule {
pub profile_name: String,
pub platform: String,
pub enabled: bool,
/// Minutes past local midnight the run is anchored to.
/// Minutes past local midnight the FIRST slot is anchored to. The server
/// mirrors `slots[0]` onto this pair on every write.
pub run_at_minute: u16,
/// Bitmask of local weekdays, bit 0 = Monday.
/// The first slot's weekdays, bit 0 = Monday. See `run_at_minute`.
pub days_mask: u8,
/// Every time-of-day this enrolment fires.
///
/// `default` rather than required because a server older than multi-slot
/// scheduling sends only the mirrored pair above, and a decode failure there
/// would blank the whole Cookie Bot surface rather than show one time instead
/// of several. Callers must therefore fall back to the pair when this is
/// empty — never treat an empty list as "fires at no time".
#[serde(default)]
pub slots: Vec<CookieBotSlot>,
pub timezone: String,
/// Server-issued preset id. Opaque here — what it expands to is infra's.
pub preset: String,
/// The template the sites came from, or `None` for the user's own list.
///
/// A built-in id (`low-intent-purchaser`) means `sites` is EMPTY on purpose:
/// its URLs are server-owned and never sent to a client. A `user:<uuid>` id
/// is provenance only — those sites were copied onto the enrolment and are
/// present below.
#[serde(default)]
pub template_id: Option<String>,
pub max_minutes: u32,
#[serde(default)]
pub sites: Vec<String>,
@@ -120,6 +164,11 @@ pub struct CookieBotSchedule {
pub encrypted_sync: bool,
#[serde(default)]
pub has_proxy: bool,
/// Whether that exit is one a leased fleet host could dial. Defaults to false
/// on an older server that does not send it, which reads as "not reachable"
/// and is the safe direction.
#[serde(default)]
pub proxy_remote_reachable: bool,
#[serde(default)]
pub touch_fingerprint: bool,
#[serde(default)]
@@ -162,8 +211,26 @@ pub struct CookieBotScheduleInput {
pub enabled: bool,
pub run_at_minute: u16,
pub days_mask: u8,
/// The whole calendar, when the caller has one.
///
/// `skip_serializing_if` is load-bearing rather than tidiness: the server
/// reads an ABSENT `slots` as "one slot, from the pair above" and refuses a
/// present-but-empty one, and `null` takes the refusing branch. Serialising
/// `None` as null would 400 every write from a single-slot form.
///
/// The pair above is still sent, mirrored from `slots[0]`, so a server that
/// predates multi-slot stores the first time rather than nothing.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slots: Option<Vec<CookieBotSlot>>,
pub timezone: String,
pub preset: String,
/// A browsing template instead of a typed site list.
///
/// Mutually exclusive with a non-empty `sites`: the server refuses a write
/// carrying both, because merging a curated persona with the user's own list
/// produces neither. A caller naming a template sends `sites: []`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub template_id: Option<String>,
pub max_minutes: u32,
pub sites: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -191,6 +258,8 @@ pub struct CookieBotScheduleInput {
#[serde(default)]
pub has_proxy: bool,
#[serde(default)]
pub proxy_remote_reachable: bool,
#[serde(default)]
pub encrypted_sync: bool,
#[serde(default)]
pub touch_fingerprint: bool,
@@ -331,6 +400,53 @@ pub struct CookieBotPreset {
pub description: Option<String>,
}
/// A server-owned browsing template: a named answer to "what is this profile
/// for", which the user picks INSTEAD of typing a site list.
///
/// Carries no URLs, and must not gain any. The pool a template draws from is
/// server-side for the same reason a preset's browsing model is: a published
/// list is one a retailer can filter, and each profile is given its own sample
/// so the template never becomes a fleet-wide fingerprint.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotTemplate {
pub id: String,
/// How many sites this template browses. Not which.
#[serde(default)]
pub site_count: u32,
/// Server-supplied English label and blurb, present only so a template added
/// after this build still renders. The UI prefers its own `t()` key for an id
/// it recognises.
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
}
/// The bounds the schedule routes actually enforce, as this build reads them.
///
/// Every field is optional because a server that predates `limits` sends none
/// of them, and a client that read a missing bound as `0` would refuse every
/// value the form can produce. Only the bounds the desktop acts on are decoded
/// — serde drops the rest, and this struct is what the GUI ultimately receives,
/// so adding a field here is what makes one reachable from TypeScript.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotLimits {
#[serde(default)]
pub min_minutes: Option<u32>,
#[serde(default)]
pub max_minutes: Option<u32>,
#[serde(default)]
pub min_sites: Option<u32>,
#[serde(default)]
pub max_sites: Option<u32>,
/// Most entries a calendar may carry.
#[serde(default)]
pub max_slots: Option<u32>,
/// Longest name a saved site list may be given.
#[serde(default)]
pub max_template_name_length: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotPresetList {
#[serde(default)]
@@ -339,6 +455,33 @@ pub struct CookieBotPresetList {
/// preference.
#[serde(default)]
pub default_preset: Option<String>,
/// The curated templates on offer. Served beside the presets so a template
/// added server-side appears without a desktop release.
#[serde(default)]
pub templates: Vec<CookieBotTemplate>,
/// The server's own bounds, when it publishes them. The desktop mirrors a
/// copy for offline form validation; these win where they disagree.
#[serde(default)]
pub limits: Option<CookieBotLimits>,
}
/// One of the caller's OWN saved site lists.
///
/// Carries its URLs, unlike {@link CookieBotTemplate} — they are the user's own
/// and there is nothing to withhold. Applying one copies the sites onto the
/// enrolment, so a list edited later does not silently change what an existing
/// enrolment browses until it is saved again.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotUserTemplate {
/// Already carries the `user:` prefix: this id's job is to be pasted into a
/// schedule's `template_id`, and assembling that convention on the client is
/// how the two kinds of template get confused.
pub id: String,
pub name: String,
#[serde(default)]
pub sites: Vec<String>,
#[serde(default)]
pub updated_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -465,7 +608,10 @@ pub struct CookieBotUsage {
/// the client cannot see — but a profile that can never qualify should never
/// reach a confirm dialog, an hour of quota or a leased host. Returns the
/// `{"code":…}` string a Tauri command surfaces directly.
pub fn bot_precondition(profile: &BrowserProfile) -> Result<(), String> {
pub fn bot_precondition(
profile: &BrowserProfile,
exit: &crate::remote_exit::ExitReachability,
) -> Result<(), String> {
if !profile.is_sync_enabled() {
// The host materialises the profile by pulling it from donut-sync. A
// local-only profile has nothing there, so there is no path to a run.
@@ -491,6 +637,21 @@ pub fn bot_precondition(profile: &BrowserProfile) -> Result<(), String> {
// than not warming it at all.
return Err(error("COOKIE_BOT_REQUIRES_EXIT_NODE", &[]));
}
// ...and the exit has to be one the leased host can reach. The profile and its
// proxy record are pulled onto the fleet with no address rewriting, so
// 127.0.0.1 arrives meaning THAT host's loopback — an ordinary mistake (an SSH
// tunnel, a local MITM proxy, a locally-run SOCKS client), and by the time the
// run fails an hour has been leased and billed.
//
// Taken as an ARGUMENT rather than resolved here, for the same reason
// `ProfileState` is required rather than defaulted: resolving it needs the
// proxy and VPN stores, and a function that reaches into those globals is one
// no test can set up and every caller silently depends on. `exit_reachability`
// is the one place that resolution happens; this stays a pure predicate over
// facts it is handed.
if !exit.is_remote() {
return Err(error("COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE", &[]));
}
Ok(())
}
@@ -511,6 +672,12 @@ pub fn profile_state(profile: &BrowserProfile) -> ProfileState {
// A VPN is an exit node just as much as a proxy is; the server only asks
// whether the traffic leaves through something the user brought.
has_proxy: profile.proxy_id.is_some() || profile.vpn_id.is_some(),
// ...and, separately, whether anyone OTHER than this machine could use it.
// `has_proxy` answers "did the user bring an exit"; this answers "is that
// exit an address a leased host can dial". They disagree for every local
// proxy, which is the case that used to be accepted and then fail on the
// fleet. See `remote_exit`.
proxy_remote_reachable: exit_reachability(profile).is_remote(),
// Always false: this data model has no mobile/touch profile. `resolved_os`
// yields only windows, macos or linux, and `bot_precondition` already
// refuses everything but the first two. Reported rather than omitted so the
@@ -533,16 +700,69 @@ pub struct ProfileState {
pub sync_enabled: bool,
pub encrypted_sync: bool,
pub has_proxy: bool,
/// Whether that exit is an address a leased fleet host can dial.
pub proxy_remote_reachable: bool,
pub touch_fingerprint: bool,
pub sticky_exit: bool,
}
/// Whether this profile's exit could be used from a host that is not this one.
///
/// Resolves the profile's proxy or VPN out of local storage — the server cannot
/// do this, because it never sees a proxy record until sync has uploaded one and
/// even then would have to re-derive what the browser will actually dial.
///
/// A profile carrying BOTH a proxy and a VPN is judged on the proxy: that is
/// what the browser is pointed at, and it is the address the fleet has to reach.
pub fn exit_reachability(profile: &BrowserProfile) -> crate::remote_exit::ExitReachability {
use crate::remote_exit::{classify_proxy, classify_wireguard_endpoint, ExitReachability};
if let Some(proxy_id) = profile.proxy_id.as_deref() {
let stored = crate::proxy_manager::PROXY_MANAGER
.get_stored_proxies()
.into_iter()
.find(|candidate| candidate.id == proxy_id);
return match stored {
Some(proxy) => classify_proxy(&proxy.proxy_settings),
// Referenced but missing. Fail closed: a dangling id is not evidence of a
// reachable exit, and the launch would fail anyway.
None => ExitReachability::Unknown {
reason: "the profile references a proxy that no longer exists".to_string(),
source: "proxy",
},
};
}
if let Some(vpn_id) = profile.vpn_id.as_deref() {
let config = crate::vpn::VPN_STORAGE
.lock()
.ok()
.and_then(|storage| storage.load_config(vpn_id).ok());
return match config {
Some(config) => match crate::vpn::parse_wireguard_config(&config.config_data) {
Ok(parsed) => classify_wireguard_endpoint(&parsed.peer_endpoint),
Err(error) => ExitReachability::Unknown {
reason: format!("VPN config could not be parsed ({error})"),
source: "VPN",
},
},
None => ExitReachability::Unknown {
reason: "the profile references a VPN config that no longer exists".to_string(),
source: "VPN",
},
};
}
ExitReachability::None
}
impl CookieBotScheduleInput {
/// Stamp the profile facts onto an input built from user-chosen values.
pub fn with_profile_state(mut self, state: ProfileState) -> Self {
self.sync_enabled = state.sync_enabled;
self.encrypted_sync = state.encrypted_sync;
self.has_proxy = state.has_proxy;
self.proxy_remote_reachable = state.proxy_remote_reachable;
self.touch_fingerprint = state.touch_fingerprint;
self.sticky_exit = state.sticky_exit;
self
@@ -663,6 +883,10 @@ pub async fn update_profile_state(
body.insert("sync_enabled".to_string(), state.sync_enabled.into());
body.insert("encrypted_sync".to_string(), state.encrypted_sync.into());
body.insert("has_proxy".to_string(), state.has_proxy.into());
body.insert(
"proxy_remote_reachable".to_string(),
state.proxy_remote_reachable.into(),
);
body.insert(
"touch_fingerprint".to_string(),
state.touch_fingerprint.into(),
@@ -855,6 +1079,171 @@ pub async fn list_presets() -> Result<CookieBotPresetList, CookieBotError> {
.await
}
// --- User-defined templates -------------------------------------------------
//
// The caller's own saved site lists. Unlike every other route in this file
// these are addressed by an id the SERVER minted and the client echoes back,
// so each one percent-encodes it: the id is spelled `user:<uuid>`, and a bare
// colon in a path segment is a spelling the router is free to read differently.
#[derive(Debug, Deserialize)]
struct UserTemplateListEnvelope {
#[serde(default)]
templates: Vec<CookieBotUserTemplate>,
}
#[derive(Debug, Deserialize)]
struct UserTemplateEnvelope {
template: CookieBotUserTemplate,
}
#[derive(Debug, Deserialize)]
struct UserTemplateDeleted {
#[serde(default)]
deleted: bool,
}
/// Every site list this user has saved, most recently edited first.
pub async fn list_user_templates() -> Result<Vec<CookieBotUserTemplate>, CookieBotError> {
let envelope: UserTemplateListEnvelope = request(
reqwest::Method::GET,
format!("{}/user-templates", base()),
Vec::new(),
None,
TEMPLATE_CODES,
)
.await?;
Ok(envelope.templates)
}
/// Save a new one.
pub async fn create_user_template(
name: &str,
sites: &[String],
) -> Result<CookieBotUserTemplate, CookieBotError> {
let body = serde_json::json!({ "name": name, "sites": sites });
let envelope: UserTemplateEnvelope = request(
reqwest::Method::POST,
format!("{}/user-templates", base()),
Vec::new(),
Some(body),
TEMPLATE_CODES,
)
.await?;
Ok(envelope.template)
}
/// Rename one, replace its sites, or both.
///
/// A PATCH with only the fields that changed, because the two are independent:
/// a rename that had to carry the whole site list is a rename that silently
/// reverts an edit made to it from another device in the meantime. Sending an
/// omitted field as `null` would defeat that, so each is skipped when absent.
pub async fn update_user_template(
id: &str,
name: Option<&str>,
sites: Option<&[String]>,
) -> Result<CookieBotUserTemplate, CookieBotError> {
let mut body = serde_json::Map::new();
if let Some(name) = name {
body.insert(
"name".to_string(),
serde_json::Value::String(name.to_string()),
);
}
if let Some(sites) = sites {
body.insert("sites".to_string(), serde_json::json!(sites));
}
let envelope: UserTemplateEnvelope = request(
reqwest::Method::PATCH,
format!("{}/user-templates/{}", base(), urlencoding::encode(id)),
Vec::new(),
Some(serde_json::Value::Object(body)),
TEMPLATE_CODES,
)
.await?;
Ok(envelope.template)
}
/// Delete one. Enrolments that used it keep the sites they copied, so this is
/// never a way to stop a profile being warmed tonight.
///
/// Safe to repeat: deleting a list that is already gone answers `false` rather
/// than 404, which is what makes a retry after a dropped response harmless.
pub async fn delete_user_template(id: &str) -> Result<bool, CookieBotError> {
let deleted: UserTemplateDeleted = request(
reqwest::Method::DELETE,
format!("{}/user-templates/{}", base(), urlencoding::encode(id)),
Vec::new(),
None,
TEMPLATE_CODES,
)
.await?;
Ok(deleted.deleted)
}
// --- Tauri commands ---------------------------------------------------------
//
// The user-template commands live here rather than in `lib.rs` beside the
// schedule ones because they carry no local precondition: nothing about a saved
// site list depends on a profile this machine holds, so there is no profile to
// look up and no `bot_precondition` to apply. They must still be registered in
// `lib.rs`'s `invoke_handler` to be reachable.
/// Log a refusal and hand the frontend the envelope it translates.
///
/// The raw HTTP text never reaches the user: an untranslated backend sentence
/// in a Japanese UI is the failure the `{"code":…}` convention exists to stop.
fn command_error(context: &str, err: CookieBotError) -> String {
log::warn!(
"Cookie bot {context} failed: {} (HTTP {})",
err.code(),
err.status()
);
err.to_error_json()
}
/// Every site list this user has saved.
#[tauri::command]
pub async fn get_cookie_bot_user_templates() -> Result<Vec<CookieBotUserTemplate>, String> {
list_user_templates()
.await
.map_err(|e| command_error("template list", e))
}
/// Save the current site list under a name.
#[tauri::command]
pub async fn create_cookie_bot_user_template(
name: String,
sites: Vec<String>,
) -> Result<CookieBotUserTemplate, String> {
create_user_template(&name, &sites)
.await
.map_err(|e| command_error("template create", e))
}
/// Rename a saved list, replace its sites, or both. Omitted fields are left
/// exactly as they are.
#[tauri::command]
pub async fn update_cookie_bot_user_template(
id: String,
name: Option<String>,
sites: Option<Vec<String>>,
) -> Result<CookieBotUserTemplate, String> {
update_user_template(&id, name.as_deref(), sites.as_deref())
.await
.map_err(|e| command_error("template update", e))
}
/// Delete a saved list. `false` means there was nothing left to delete.
#[tauri::command]
pub async fn delete_cookie_bot_user_template(id: String) -> Result<bool, String> {
delete_user_template(&id)
.await
.map_err(|e| command_error("template delete", e))
}
/// Per-member and per-profile spend for a calendar month (`YYYY-MM`).
pub async fn team_usage(period: Option<&str>) -> Result<CookieBotUsage, CookieBotError> {
let query = period
@@ -976,6 +1365,7 @@ async fn request<T: DeserializeOwned>(
mod tests {
use super::*;
use crate::profile::types::SyncMode;
use crate::remote_exit::ExitReachability;
fn eligible_profile() -> BrowserProfile {
BrowserProfile {
@@ -1045,7 +1435,8 @@ mod tests {
// that emptiness over the user's real profile.
let mut profile = eligible_profile();
profile.sync_mode = SyncMode::Disabled;
let err = bot_precondition(&profile).expect_err("a local-only profile must be refused");
let err = bot_precondition(&profile, &ExitReachability::Remote)
.expect_err("a local-only profile must be refused");
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_CLOUD_SYNC");
}
@@ -1055,7 +1446,8 @@ mod tests {
// one code cannot carry two different instructions.
let mut profile = eligible_profile();
profile.sync_mode = SyncMode::Encrypted;
let err = bot_precondition(&profile).expect_err("encrypted sync must be refused");
let err = bot_precondition(&profile, &ExitReachability::Remote)
.expect_err("encrypted sync must be refused");
assert_eq!(code_of(&err), "COOKIE_BOT_ENCRYPTED_SYNC_UNSUPPORTED");
}
@@ -1063,7 +1455,8 @@ mod tests {
fn linux_is_refused_at_enrolment_rather_than_at_two_in_the_morning() {
let mut profile = eligible_profile();
profile.host_os = Some("linux".to_string());
let err = bot_precondition(&profile).expect_err("linux has no host to lease");
let err = bot_precondition(&profile, &ExitReachability::Remote)
.expect_err("linux has no host to lease");
let parsed: serde_json::Value = serde_json::from_str(&err).expect("valid envelope");
assert_eq!(parsed["code"], "COOKIE_BOT_UNSUPPORTED_PLATFORM");
assert_eq!(
@@ -1076,7 +1469,8 @@ mod tests {
fn a_profile_with_no_recorded_os_cannot_be_scheduled_onto_a_host() {
let mut profile = eligible_profile();
profile.host_os = None;
let err = bot_precondition(&profile).expect_err("no OS means no matching host");
let err = bot_precondition(&profile, &ExitReachability::Remote)
.expect_err("no OS means no matching host");
assert_eq!(code_of(&err), "COOKIE_BOT_UNKNOWN_PLATFORM");
}
@@ -1087,7 +1481,8 @@ mod tests {
let mut profile = eligible_profile();
profile.proxy_id = None;
profile.vpn_id = None;
let err = bot_precondition(&profile).expect_err("datacenter egress must be refused");
let err = bot_precondition(&profile, &ExitReachability::None)
.expect_err("datacenter egress must be refused");
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_EXIT_NODE");
}
@@ -1096,21 +1491,60 @@ mod tests {
let mut profile = eligible_profile();
profile.proxy_id = None;
profile.vpn_id = Some("vpn-1".to_string());
assert!(bot_precondition(&profile).is_ok());
assert!(bot_precondition(&profile, &ExitReachability::Remote).is_ok());
}
#[test]
fn a_windows_profile_with_sync_and_a_proxy_qualifies() {
let mut profile = eligible_profile();
profile.host_os = Some("windows".to_string());
assert!(bot_precondition(&profile).is_ok());
assert!(bot_precondition(&profile, &ExitReachability::Remote).is_ok());
}
#[test]
fn an_exit_only_this_machine_can_reach_is_refused() {
// The gap `has_proxy` alone could never see, and — before the verdict became
// an argument — a case no unit test could construct, because resolving it
// reached into the global proxy store. The profile is otherwise perfect.
let profile = eligible_profile();
let err = bot_precondition(
&profile,
&ExitReachability::LocalOnly {
host: "127.0.0.1".to_string(),
source: "proxy",
},
)
.expect_err("a loopback exit cannot be dialled from a leased host");
// Its own code: "attach a proxy" is unactionable advice for someone whose
// proxy is plainly attached.
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE");
}
#[test]
fn an_exit_we_could_not_read_is_refused_too() {
// Fails closed. Refusing a working setup costs one support question;
// accepting a broken one burns a leased hour and damages an identity.
let err = bot_precondition(
&eligible_profile(),
&ExitReachability::Unknown {
reason: "VPN config could not be parsed".to_string(),
source: "VPN",
},
)
.expect_err("an unreadable exit must not be assumed reachable");
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE");
}
/// A verbatim `CookieBotScheduleView`, field for field, as `toScheduleView`
/// in donutbrowser-infra's `cookie-bot.service.ts` builds it.
const SERVER_SCHEDULE_VIEW: &str = r#"{
"profile_id":"p1","profile_name":"Yu","platform":"macos","enabled":true,
"run_at_minute":120,"days_mask":127,"timezone":"Europe/Berlin",
"run_at_minute":120,"days_mask":127,
"slots":[{"days_mask":127,"run_at_minute":120},{"days_mask":31,"run_at_minute":690}],
"timezone":"Europe/Berlin","template_id":null,
"preset":"balanced","max_minutes":45,"sites":["https://example.com"],
"jitter_seconds":900,"sync_enabled":true,"encrypted_sync":false,
"has_proxy":true,"touch_fingerprint":false,"sticky_exit":false,
@@ -1142,6 +1576,132 @@ mod tests {
assert!(schedule.blocked_by.is_none());
}
#[test]
fn a_schedule_carries_its_whole_calendar_not_just_the_first_time() {
// The mirrored pair is `slots[0]`, so a client that read only the pair
// would show "every night at 02:00" for an enrolment that also runs at
// 11:30 on weeknights — fewer runs than the user booked, silently.
let schedule: CookieBotSchedule =
serde_json::from_str(SERVER_SCHEDULE_VIEW).expect("a multi-slot schedule must deserialize");
assert_eq!(schedule.slots.len(), 2);
assert_eq!(schedule.slots[0].run_at_minute, schedule.run_at_minute);
assert_eq!(schedule.slots[0].days_mask, schedule.days_mask);
assert_eq!(schedule.slots[1].run_at_minute, 690);
assert_eq!(schedule.slots[1].days_mask, 31);
}
#[test]
fn a_server_that_predates_multi_slot_still_decodes_with_no_slots() {
// `slots` absent is a deployment that has not rolled forward, not a broken
// enrolment. Requiring it would blank the whole Cookie Bot surface against
// an older backend rather than show the one time it does know about.
let schedule: CookieBotSchedule = serde_json::from_str(
r#"{"profile_id":"p1","profile_name":"Yu","platform":"windows","enabled":true,
"run_at_minute":120,"days_mask":31,"timezone":"UTC","preset":"light",
"max_minutes":10}"#,
)
.expect("a pre-multi-slot schedule must deserialize");
assert!(schedule.slots.is_empty());
assert!(schedule.template_id.is_none());
}
#[test]
fn a_templated_enrolment_reports_its_template_and_no_sites() {
// A built-in template's URLs are server-owned. An empty `sites` here is the
// contract working, not a schedule with nothing to browse — anything that
// reads it as "no sites" would show a healthy enrolment as broken.
let schedule: CookieBotSchedule = serde_json::from_str(
&SERVER_SCHEDULE_VIEW
.replace(
"\"template_id\":null",
"\"template_id\":\"low-intent-purchaser\"",
)
.replace("\"sites\":[\"https://example.com\"]", "\"sites\":[]"),
)
.expect("a templated schedule must deserialize");
assert_eq!(
schedule.template_id.as_deref(),
Some("low-intent-purchaser")
);
assert!(schedule.sites.is_empty());
assert!(schedule.blocked_by.is_none());
}
#[test]
fn a_calendar_is_sent_as_slots_and_omitted_entirely_when_there_is_none() {
// The server reads an ABSENT `slots` as "one slot, from the legacy pair"
// and REFUSES a null or empty one. Serialising `None` as null would 400
// every write from a form with a single time on it.
let one_slot = CookieBotScheduleInput {
profile_name: "Yu".to_string(),
platform: "macos".to_string(),
enabled: true,
run_at_minute: 120,
days_mask: 127,
timezone: "Europe/Berlin".to_string(),
preset: "balanced".to_string(),
max_minutes: 45,
sites: vec!["https://example.com".to_string()],
..Default::default()
};
let encoded = serde_json::to_value(&one_slot).expect("input must serialize");
assert!(
encoded.get("slots").is_none(),
"an absent calendar must be absent on the wire, not null"
);
assert!(encoded.get("template_id").is_none());
let many = CookieBotScheduleInput {
slots: Some(vec![
CookieBotSlot {
days_mask: 127,
run_at_minute: 120,
},
CookieBotSlot {
days_mask: 31,
run_at_minute: 690,
},
]),
..one_slot
};
let encoded = serde_json::to_value(&many).expect("input must serialize");
let slots = encoded["slots"].as_array().expect("slots must be a list");
assert_eq!(slots.len(), 2);
// Mirrored, because a server that predates multi-slot ignores `slots` and
// stores this pair. Dropping it would leave that server with no time at all.
assert_eq!(encoded["run_at_minute"], 120);
assert_eq!(encoded["days_mask"], 127);
}
#[test]
fn a_templated_write_names_the_template_and_sends_no_sites() {
// The server refuses a body carrying both: a curated persona merged with
// the user's own list is neither.
let input = CookieBotScheduleInput {
profile_name: "Yu".to_string(),
platform: "macos".to_string(),
enabled: true,
run_at_minute: 120,
days_mask: 127,
timezone: "UTC".to_string(),
preset: "balanced".to_string(),
max_minutes: 45,
sites: Vec::new(),
template_id: Some("low-intent-purchaser".to_string()),
..Default::default()
};
let encoded = serde_json::to_value(&input).expect("input must serialize");
assert_eq!(encoded["template_id"], "low-intent-purchaser");
assert_eq!(
encoded["sites"].as_array().map(Vec::len),
Some(0),
"sites must still be sent, and must be empty, beside a template"
);
}
#[test]
fn a_broken_enrolment_carries_the_reason_it_cannot_run() {
// The whole point of `blocked_by`: a profile whose proxy was detached in
@@ -1419,5 +1979,76 @@ mod tests {
assert_eq!(presets.presets[0].id, "balanced");
assert_eq!(presets.presets[0].typical_minutes, Some(35));
assert_eq!(presets.default_preset.as_deref(), Some("balanced"));
// An older deployment sends neither of these, and the dialog has to render
// against it: no templates simply means the picker offers the user's own
// list, and no limits means the mirrored bounds apply.
assert!(presets.templates.is_empty());
assert!(presets.limits.is_none());
}
#[test]
fn a_template_crosses_the_wire_as_a_count_and_never_as_urls() {
// The pool is server-owned for the same reason a preset's browsing model
// is. If this type ever gained a `sites` field the curation would be
// published, and a published list is one a retailer can filter.
let presets: CookieBotPresetList = serde_json::from_str(
r#"{"presets":[],"default_preset":"balanced",
"templates":[{"id":"low-intent-purchaser","site_count":32,
"name":"Low-Intent Purchaser","description":"Price-sensitive browsing."}],
"limits":{"min_minutes":5,"max_minutes":120,"min_sites":1,"max_sites":40,
"max_site_length":2048,"max_jitter_seconds":3600,"max_slots":14,
"max_template_name_length":80}}"#,
)
.expect("the preset list must carry templates and limits");
assert_eq!(presets.templates[0].id, "low-intent-purchaser");
assert_eq!(presets.templates[0].site_count, 32);
let limits = presets.limits.expect("limits must decode");
assert_eq!(limits.max_slots, Some(14));
assert_eq!(limits.max_template_name_length, Some(80));
assert_eq!(limits.max_sites, Some(40));
}
#[test]
fn a_saved_list_arrives_with_the_prefix_a_schedule_write_needs() {
// The id is what `template_id` takes verbatim. Handing the client a bare
// uuid and expecting it to prepend `user:` is how a saved list gets looked
// up against the built-in catalogue instead — which answers "no sites" and
// silently unschedules the profile.
let envelope: UserTemplateListEnvelope = serde_json::from_str(
r#"{"templates":[{"id":"user:1c9a…","name":"My shops",
"sites":["https://example.com"],"updated_at":"2026-08-05T10:00:00.000Z"}]}"#,
)
.expect("the user template list must deserialize");
let template = &envelope.templates[0];
assert!(template.id.starts_with("user:"));
assert_eq!(template.name, "My shops");
assert_eq!(template.sites.len(), 1);
}
#[test]
fn deleting_a_saved_list_that_is_already_gone_is_not_a_failure() {
// The route never 404s, so a delete retried after a dropped response has to
// read as "nothing left to do" rather than as an error the user must act on.
let deleted: UserTemplateDeleted =
serde_json::from_str(r#"{"deleted":false,"id":"user:gone"}"#)
.expect("a no-op delete must deserialize");
assert!(!deleted.deleted);
}
#[test]
fn a_template_404_is_a_missing_list_and_not_an_unenrolled_profile() {
// Sharing SCHEDULE_CODES here would tell someone renaming a site list that
// their profile is not enrolled, and a name collision that a teammate
// already warms the profile.
assert_eq!(
cloud_errors::classify_message("(404) Not Found", TEMPLATE_CODES).code,
"COOKIE_BOT_TEMPLATE_NOT_FOUND"
);
assert_eq!(
cloud_errors::classify_message("(409) Conflict", TEMPLATE_CODES).code,
"COOKIE_BOT_TEMPLATE_NAME_TAKEN"
);
}
}
+50 -36
View File
@@ -156,6 +156,9 @@ async fn enforce_direct_exit(
profile: &BrowserProfile,
gate: &FingerprintGate,
) -> Result<(), String> {
if gate_disabled() {
return Ok(());
}
if crate::launch_gate_prefs::fingerprint_ack_matches(profile, DIRECT_EXIT_IDENTITY) {
return Ok(());
}
@@ -207,22 +210,28 @@ pub async fn enforce_fingerprint_gate(
if upstream.is_none() && !declares_route {
return Ok(());
}
let Some(key) = fingerprint_consistency::exit_cache_key(profile) else {
// Declares a route we can no longer resolve at all (e.g. the stored proxy
// was deleted). Nothing identifies the exit, so measure the direct one.
if declares_route {
log::warn!(
"Fingerprint gate: {} declares a proxy/VPN that did not resolve; \
measuring the direct exit it will actually use",
profile.name
);
}
return enforce_direct_exit(profile, gate).await;
};
if gate_disabled() {
return Ok(());
}
// Decide *once*, before any consent handling, whether this launch is going
// out directly. Both a route that no longer resolves (deleted proxy) and one
// that produced no usable upstream (a VPN worker with no local port) end up
// connecting directly, and both must mint and redeem consent under the same
// identity — splitting that decision across the function meant the first
// attempt minted under "direct" while the retry redeemed against the proxy
// identity, so "Launch anyway" could never succeed.
let key = fingerprint_consistency::exit_cache_key(profile);
if key.is_none() || upstream.is_none() {
log::warn!(
"Fingerprint gate: {} declares a proxy/VPN that yielded no usable upstream; \
measuring the direct exit it will actually use",
profile.name
);
return enforce_direct_exit(profile, gate).await;
}
let key = key.expect("checked above");
// Ack first: a persisted acknowledgement already permits this launch, so a
// stale token must not turn it into a hard failure.
if crate::launch_gate_prefs::fingerprint_ack_matches(profile, &key.identity) {
@@ -234,16 +243,6 @@ pub async fn enforce_fingerprint_gate(
return Ok(());
}
// The route is known but produced no usable upstream (e.g. a VPN worker that
// came up without a local port). The browser still launches, direct.
if upstream.is_none() {
log::warn!(
"Fingerprint gate: {} has a route that yielded no upstream; measuring the direct exit",
profile.name
);
return enforce_direct_exit(profile, gate).await;
}
let result = if matches!(gate, FingerprintGate::Advisory) {
// Automation: answer from a warm cache or say nothing. Probing here would
// add seconds to every profile in a batch run.
@@ -390,9 +389,13 @@ pub async fn ack_launch_gate(
let profile = load_profile(&profile_id)?;
if ack_fingerprint {
if let Some(key) = fingerprint_consistency::exit_cache_key(&profile) {
crate::launch_gate_prefs::ack_fingerprint(&profile, &key.identity);
}
// Must match the identity the block was issued against. A profile whose
// route did not resolve is gated on the direct exit and has no cache key,
// so falling back here is what makes "don't block again" stick for it.
let identity = fingerprint_consistency::exit_cache_key(&profile)
.map(|key| key.identity)
.unwrap_or_else(|| DIRECT_EXIT_IDENTITY.to_string());
crate::launch_gate_prefs::ack_fingerprint(&profile, &identity);
}
crate::launch_gate_prefs::ack_extensions(&profile_id, &ack_extension_keys);
Ok(())
@@ -506,21 +509,32 @@ mod tests {
#[tokio::test]
async fn gate_allows_a_profile_with_no_proxy_or_vpn() {
// No exit identity means nothing to compare against, so the launch must
// proceed rather than block on an unmeasurable profile.
// A profile that declares no route has no upstream either — that pairing is
// the only one the launcher can actually produce. It must return without
// measuring anything, so this stays a pure unit test with no network.
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
let upstream = crate::browser::ProxySettings {
proxy_type: "socks5".into(),
host: "127.0.0.1".into(),
port: 1080,
username: None,
password: None,
vless_uri: None,
};
assert!(
enforce_fingerprint_gate(&profile, Some(&upstream), &FingerprintGate::Enforce)
enforce_fingerprint_gate(&profile, None, &FingerprintGate::Enforce)
.await
.is_ok()
);
}
#[tokio::test]
async fn consent_for_a_direct_launch_is_redeemable_by_the_gate() {
// Regression: a route that yields no usable upstream is gated on the direct
// exit, so consent is minted under DIRECT_EXIT_IDENTITY. If the gate then
// redeemed against the proxy/VPN identity instead, "Launch anyway" would
// fail forever and the profile could never be started.
let mut profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
profile.vpn_id = Some("vpn-with-no-port".into());
let token = mint_consent(&profile, DIRECT_EXIT_IDENTITY);
// No upstream: the launcher could not bring the route up.
let result = enforce_fingerprint_gate(&profile, None, &FingerprintGate::Consented(token)).await;
assert!(
result.is_ok(),
"consent minted for the direct exit must be redeemable, got {result:?}"
);
}
}
+91 -3
View File
@@ -23,6 +23,16 @@ pub(crate) fn backend_error_with_detail(code: &str, detail: impl std::fmt::Displ
serde_json::json!({ "code": code, "params": { "detail": detail.to_string() } }).to_string()
}
/// A VLESS URI Donut cannot use, carrying which part is unsupported so the UI
/// can say so instead of implying a typo.
pub(crate) fn vless_config_error(error: &crate::xray::XrayError) -> String {
serde_json::json!({
"code": "VLESS_CONFIG_INVALID",
"params": { "reason": error.reason_code(), "detail": error.to_string() }
})
.to_string()
}
fn e2e_automation_enabled() -> bool {
#[cfg(feature = "e2e")]
{
@@ -75,6 +85,7 @@ mod proxy_manager;
pub mod proxy_runner;
pub mod proxy_server;
pub mod proxy_storage;
mod remote_exit;
mod remote_handoff;
mod remote_session;
mod settings_manager;
@@ -84,6 +95,7 @@ mod synchronizer;
pub mod traffic_stats;
mod wayfern_manager;
mod wayfern_terms;
mod window_decorations;
// mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme
pub mod cloud_auth;
mod cloud_errors;
@@ -317,6 +329,16 @@ async fn create_stored_proxy(
}
}
/// Validate a VLESS URI without touching the network, so the proxy form can
/// tell the user their setup is unsupported while they are still editing it
/// rather than only after they try to save or launch.
#[tauri::command]
fn validate_vless_uri(uri: String) -> Result<(), String> {
crate::xray::parse_vless_uri(uri.trim())
.map(|_| ())
.map_err(|error| vless_config_error(&error))
}
#[tauri::command]
async fn get_stored_proxies() -> Result<Vec<crate::proxy_manager::StoredProxy>, String> {
Ok(crate::proxy_manager::PROXY_MANAGER.get_stored_proxies())
@@ -1447,7 +1469,7 @@ async fn save_cookie_bot_schedule(
// Refused here rather than at 02:00: a profile that can never be warmed
// should never reach a schedule row, an hour of quota or a leased host.
let profile = cookie_bot_profile(&profile_id)?;
cookie_bot::bot_precondition(&profile)?;
cookie_bot::bot_precondition(&profile, &cookie_bot::exit_reachability(&profile))?;
// The frontend sends the user's choices; the profile facts the server refuses
// a run on are stamped here, from the profile itself, so a caller cannot
// assert them.
@@ -1507,7 +1529,8 @@ async fn run_cookie_bot_now(
profile_id: String,
max_minutes: Option<u32>,
) -> Result<cookie_bot::CookieBotRunStarted, String> {
cookie_bot::bot_precondition(&cookie_bot_profile(&profile_id)?)?;
let profile = cookie_bot_profile(&profile_id)?;
cookie_bot::bot_precondition(&profile, &cookie_bot::exit_reachability(&profile))?;
cookie_bot::run_now(&profile_id, max_minutes)
.await
.map_err(|e| cookie_bot_error("run start", e))
@@ -1769,7 +1792,12 @@ pub fn run_with_builder(
.with_state_flags(
tauri_plugin_window_state::StateFlags::all()
& !tauri_plugin_window_state::StateFlags::VISIBLE
& !tauri_plugin_window_state::StateFlags::FULLSCREEN,
& !tauri_plugin_window_state::StateFlags::FULLSCREEN
// Whether the window is decorated is decided per-session by
// `window_decorations::use_client_side_decorations()`, not by what
// a previous run saved. Restoring it would put a real titlebar back
// on top of the one the app draws — or strip both.
& !tauri_plugin_window_state::StateFlags::DECORATIONS,
)
.build(),
);
@@ -1809,9 +1837,21 @@ pub fn run_with_builder(
None => win_builder,
};
// The app draws its own titlebar. macOS keeps the native one and makes
// it transparent (below); Windows and Linux drop decorations entirely and
// render their own controls.
#[cfg(target_os = "windows")]
let win_builder = win_builder.decorations(false);
// Linux opts out on the one configuration where dropping decorations can
// make things worse rather than better — see `use_client_side_decorations`.
#[cfg(target_os = "linux")]
let win_builder = if window_decorations::use_client_side_decorations() {
win_builder.decorations(false)
} else {
win_builder
};
#[allow(unused_variables)]
let window = win_builder.build().unwrap();
@@ -1844,6 +1884,44 @@ pub fn run_with_builder(
});
}
// Publish the desktop's titlebar button layout to the frontend. Runs
// here because `setup` is the GTK main thread, which `gtk::Settings`
// requires.
//
// The decorated state is logged alongside it: "my window has no titlebar"
// and "my window has two titlebars" are both reports that hinge on this
// one boolean, and it is otherwise invisible after the fact.
#[cfg(target_os = "linux")]
{
log::info!(
"Linux window decorations: server-side = {:?}",
window.is_decorated()
);
// tao makes the window visible before it clears the decorations, so it
// is realized while still framed and the frame extents come out of the
// size we asked for (a requested 880x500 arrives noticeably smaller).
//
// Only correct that on a first run. Once window-state has geometry
// saved, that geometry is the user's and has already been restored —
// re-applying the default here would move and resize their window on
// every launch, and the plugin would then persist the reset.
let has_saved_geometry = app
.path()
.app_config_dir()
.map(|dir| dir.join(".window-state.json").exists())
.unwrap_or(false);
if window_decorations::use_client_side_decorations() && !has_saved_geometry {
if let Err(e) = window.set_size(tauri::LogicalSize::new(880.0, 500.0)) {
log::warn!("Failed to re-apply the window size after dropping decorations: {e}");
}
if let Err(e) = window.center() {
log::warn!("Failed to re-center the window after dropping decorations: {e}");
}
}
}
window_decorations::init(app.handle());
// Set transparent titlebar for macOS
#[cfg(target_os = "macos")]
{
@@ -2679,6 +2757,8 @@ pub fn run_with_builder(
fingerprint_consistency::match_profile_fingerprint_to_exit,
launch_gate::get_profile_pre_launch_checks,
launch_gate::ack_launch_gate,
window_decorations::get_window_decoration_layout,
validate_vless_uri,
get_sync_settings,
save_sync_settings,
set_profile_sync_mode,
@@ -2775,6 +2855,14 @@ pub fn run_with_builder(
get_cookie_bot_presets,
get_remote_hours_quota,
get_cookie_bot_usage,
// Defined in `cookie_bot.rs` rather than here because they carry no local
// precondition — there is no profile to look up and no `bot_precondition`
// to apply. Unregistered they are unreachable, and the saved-list tab
// fails at runtime with "command not found" rather than at build time.
cookie_bot::get_cookie_bot_user_templates,
cookie_bot::create_cookie_bot_user_template,
cookie_bot::update_cookie_bot_user_template,
cookie_bot::delete_cookie_bot_user_template,
// Profile password commands
set_profile_password,
change_profile_password,
+26 -13
View File
@@ -2341,11 +2341,10 @@ impl McpServer {
"check_cookie_bot_conflicts" => Self::handle_check_cookie_bot_conflicts(arguments).await,
"list_cookie_bot_runs" => Self::handle_list_cookie_bot_runs(arguments).await,
"run_cookie_bot_now" => {
Self::require_capability(
"Browser automation",
CLOUD_AUTH.can_use_browser_automation().await,
)
.await?;
// The Cookie Bot, NOT browser automation. Solo pays for the bot and has
// no automation; gating this on automation refused a Solo customer the
// feature their plan is sold on while their scheduled runs kept firing.
Self::require_capability("Cookie Bot", CLOUD_AUTH.can_use_cookie_bot().await).await?;
Self::handle_run_cookie_bot_now(arguments).await
}
// No capability gate on the cancel. A lapsed plan must never be the
@@ -5730,10 +5729,11 @@ impl McpServer {
message: format!("Profile not found: {profile_id}"),
})?;
crate::cookie_bot::bot_precondition(&profile).map_err(|message| McpError {
code: -32000,
message,
})?;
crate::cookie_bot::bot_precondition(&profile, &crate::cookie_bot::exit_reachability(&profile))
.map_err(|message| McpError {
code: -32000,
message,
})?;
Ok(profile)
}
@@ -6177,19 +6177,28 @@ mod tests {
..Default::default()
};
assert!(crate::cookie_bot::bot_precondition(&eligible()).is_ok());
assert!(crate::cookie_bot::bot_precondition(
&eligible(),
&crate::remote_exit::ExitReachability::Remote
)
.is_ok());
let mut local_only = eligible();
local_only.sync_mode = SyncMode::Disabled;
assert!(
crate::cookie_bot::bot_precondition(&local_only).is_err(),
crate::cookie_bot::bot_precondition(
&local_only,
&crate::remote_exit::ExitReachability::Remote
)
.is_err(),
"a profile with no cloud copy has nothing for a host to open"
);
let mut linux = eligible();
linux.host_os = Some("linux".to_string());
assert!(
crate::cookie_bot::bot_precondition(&linux).is_err(),
crate::cookie_bot::bot_precondition(&linux, &crate::remote_exit::ExitReachability::Remote)
.is_err(),
"the fleet cannot lease a linux host"
);
@@ -6197,7 +6206,11 @@ mod tests {
datacenter_egress.proxy_id = None;
datacenter_egress.vpn_id = None;
assert!(
crate::cookie_bot::bot_precondition(&datacenter_egress).is_err(),
crate::cookie_bot::bot_precondition(
&datacenter_egress,
&crate::remote_exit::ExitReachability::None
)
.is_err(),
"hours of traffic from a hosting ASN damages the identity being warmed"
);
}
+3 -3
View File
@@ -464,10 +464,10 @@ impl ProxyManager {
.as_deref()
.filter(|uri| !uri.is_empty())
.ok_or_else(|| crate::backend_error("VLESS_CONFIG_INVALID"))?;
let parsed = crate::xray::parse_vless_uri(uri)
.map_err(|error| crate::backend_error_with_detail("VLESS_CONFIG_INVALID", error))?;
let parsed =
crate::xray::parse_vless_uri(uri).map_err(|error| crate::vless_config_error(&error))?;
let canonical_uri = crate::xray::export_vless_uri(&parsed.config, parsed.name.as_deref())
.map_err(|error| crate::backend_error_with_detail("VLESS_CONFIG_INVALID", error))?;
.map_err(|error| crate::vless_config_error(&error))?;
proxy_settings.proxy_type = "vless".to_string();
proxy_settings.host = parsed.config.address;
+511
View File
@@ -0,0 +1,511 @@
//! Whether a profile's exit node can be reached from somewhere that is not this
//! machine.
//!
//! Remote execution — an interactive remote session or a Cookie Bot night — runs
//! the browser on a leased fleet host, but the PROFILE (and its proxy, and its
//! VPN config) is pulled from the user's sync namespace. Nothing in that
//! handover rewrites addresses, so a proxy recorded as `127.0.0.1:8080` arrives
//! on the fleet host meaning *the fleet host's own loopback*.
//!
//! That is the whole bug this module exists to prevent. The server already
//! refuses a profile with NO exit (`proxy_required`), because a night browsed
//! from the fleet's datacenter address damages an identity rather than building
//! it — but it was asking whether an exit was *configured*, never whether it was
//! *reachable*. A local proxy satisfied the first question and failed the
//! second, so the run was accepted, dispatched, and burned a leased host either
//! erroring out or (worse) egressing direct from the datacenter: exactly the
//! outcome `proxy_required` exists to stop, reached by the one route it did not
//! check.
//!
//! Local proxies are not an exotic case. A local MITM proxy, an SSH tunnel, a
//! locally-run SOCKS client and Donut's own VLESS support all present to the
//! browser as `127.0.0.1:<port>`.
//!
//! This module is the single answer, shared by every caller, and it FAILS
//! CLOSED: anything it cannot parse is reported as unreachable. Refusing a
//! working setup costs the user one support question; accepting a broken one
//! costs a burned hour and a damaged profile identity.
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
/// Whether a leased fleet host could dial this profile's exit.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExitReachability {
/// No proxy and no VPN. The caller's existing "no exit" refusal applies.
None,
/// An address a host elsewhere on the internet can reach.
Remote,
/// An address that only means anything on this machine or this LAN.
LocalOnly {
/// The offending host, for a message the user can act on.
host: String,
/// Which part of the config it came from: "proxy" or "VPN".
source: &'static str,
},
/// Configured, but this code could not determine the host.
///
/// Treated as unreachable by [`ExitReachability::is_remote`] — see the
/// fail-closed note in the module docs.
Unknown {
reason: String,
source: &'static str,
},
}
impl ExitReachability {
/// Whether remote execution may proceed.
pub fn is_remote(&self) -> bool {
matches!(self, ExitReachability::Remote)
}
/// A one-line reason for a refusal, or None when there is nothing to refuse.
pub fn refusal_detail(&self) -> Option<String> {
match self {
ExitReachability::Remote | ExitReachability::None => None,
ExitReachability::LocalOnly { host, source } => Some(format!(
"The {source} for this profile points at {host}, which only exists on this computer. \
Remote runs happen on our hosts and cannot reach it."
)),
ExitReachability::Unknown { reason, source } => Some(format!(
"The {source} for this profile could not be read ({reason}), so we cannot confirm a \
remote host could use it."
)),
}
}
}
/// Whether a hostname or IP literal is reachable from another machine.
///
/// Rejects, in order: empty/whitespace, unparsable-as-either, and every IP
/// range that is scoped to a machine or a private network. Hostnames that are
/// not IP literals are accepted unless they use a name suffix that is
/// definitionally local — a public DNS name cannot be validated here without a
/// lookup, and doing a lookup would make this impure and slow on a hot path.
pub fn host_is_remote_reachable(host: &str) -> bool {
let host = normalize_host(host);
if host.is_empty() {
return false;
}
if let Ok(ip) = host.parse::<IpAddr>() {
return ip_is_remote_reachable(ip);
}
let lower = host.to_ascii_lowercase();
// `localhost` and anything under it resolve to loopback everywhere.
if lower == "localhost" || lower.ends_with(".localhost") {
return false;
}
// Suffixes reserved for local/private name resolution (RFC 6762 mDNS, RFC
// 8375, and the names router vendors hand out on a LAN). A fleet host
// resolving one of these gets its own network's answer, not the user's.
const LOCAL_SUFFIXES: [&str; 7] = [
".local",
".localdomain",
".internal",
".home",
".home.arpa",
".lan",
".intranet",
];
if LOCAL_SUFFIXES.iter().any(|suffix| lower.ends_with(suffix)) {
return false;
}
// A bare single-label name ("my-proxy", "router") is only resolvable through
// a local search domain, so it is no more use to a fleet host than `.local`.
if !lower.contains('.') {
return false;
}
true
}
/// Whether an IP literal is routable from another machine.
fn ip_is_remote_reachable(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => ipv4_is_remote_reachable(v4),
IpAddr::V6(v6) => ipv6_is_remote_reachable(v6),
}
}
fn ipv4_is_remote_reachable(ip: Ipv4Addr) -> bool {
// `is_private`/`is_loopback`/`is_link_local` are stable; the rest are not, so
// the remaining ranges are spelled out rather than gated behind a nightly
// feature.
if ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_unspecified() {
return false;
}
if ip.is_broadcast() || ip.is_multicast() || ip.is_documentation() {
return false;
}
let [a, b, ..] = ip.octets();
// 100.64.0.0/10 — carrier-grade NAT (RFC 6598). Reachable inside one
// carrier's network and nowhere else.
if a == 100 && (64..128).contains(&b) {
return false;
}
// 0.0.0.0/8 "this network", and 240.0.0.0/4 reserved.
if a == 0 || a >= 240 {
return false;
}
true
}
fn ipv6_is_remote_reachable(ip: Ipv6Addr) -> bool {
if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() {
return false;
}
// An IPv4 address wearing an IPv6 hat is still that IPv4 address — classify
// it as one, or `::ffff:127.0.0.1` walks straight through.
if let Some(v4) = ip.to_ipv4_mapped() {
return ipv4_is_remote_reachable(v4);
}
if let Some(v4) = ip.to_ipv4() {
return ipv4_is_remote_reachable(v4);
}
let segments = ip.segments();
// fc00::/7 unique-local, fe80::/10 link-local.
if (segments[0] & 0xfe00) == 0xfc00 {
return false;
}
if (segments[0] & 0xffc0) == 0xfe80 {
return false;
}
true
}
/// Strip the decoration a host can arrive wrapped in: whitespace, `[...]`
/// around an IPv6 literal, a trailing dot on an FQDN, and any `user@` or
/// `:port` that came along from a URI.
fn normalize_host(raw: &str) -> String {
let mut host = raw.trim();
if host.is_empty() {
return String::new();
}
// `user:pass@host` — take what follows the LAST '@', since a password may
// itself contain one.
if let Some(at) = host.rfind('@') {
host = &host[at + 1..];
}
// Bracketed IPv6, optionally with a port: `[::1]:1080`.
if let Some(stripped) = host.strip_prefix('[') {
if let Some(end) = stripped.find(']') {
return stripped[..end].trim().to_string();
}
return stripped.trim().to_string();
}
// `host:port`, but only when there is exactly one colon — more than one means
// a bare IPv6 literal, whose colons are part of the address.
if host.matches(':').count() == 1 {
if let Some((left, _port)) = host.split_once(':') {
host = left;
}
}
host.trim().trim_end_matches('.').to_string()
}
/// The host a VLESS URI actually dials.
///
/// Load-bearing because of an asymmetry that is easy to get backwards: a VLESS
/// proxy presents to the browser as `127.0.0.1:<port>` — Donut runs a local xray
/// worker and points the browser at it — but the address that decides whether
/// anyone else could use this config is the SERVER inside the URI. The local
/// port is an implementation detail of this machine; the URI is the exit.
pub fn vless_uri_host(uri: &str) -> Option<String> {
let rest = uri.trim().strip_prefix("vless://")?;
// Cut the fragment (`#label`) and query (`?type=...`) before looking for the
// authority — either may contain '@' or ':'.
let rest = rest.split('#').next()?;
let rest = rest.split('?').next()?;
// `uuid@host:port/...`
let authority = rest.split('/').next()?;
let host_port = authority
.rsplit_once('@')
.map(|(_, h)| h)
.unwrap_or(authority);
let host = normalize_host(host_port);
if host.is_empty() {
None
} else {
Some(host)
}
}
/// The exit host a stored proxy represents, as a remote host would have to dial
/// it.
pub fn proxy_exit_host(settings: &crate::browser::ProxySettings) -> Result<String, String> {
if settings.proxy_type.eq_ignore_ascii_case("vless") {
let uri = settings
.vless_uri
.as_deref()
.filter(|uri| !uri.trim().is_empty())
.ok_or_else(|| "VLESS proxy has no server URI".to_string())?;
return vless_uri_host(uri).ok_or_else(|| "VLESS server URI is malformed".to_string());
}
let host = normalize_host(&settings.host);
if host.is_empty() {
return Err("proxy has no host".to_string());
}
Ok(host)
}
/// Classify a stored proxy.
pub fn classify_proxy(settings: &crate::browser::ProxySettings) -> ExitReachability {
match proxy_exit_host(settings) {
Err(reason) => ExitReachability::Unknown {
reason,
source: "proxy",
},
Ok(host) => {
if host_is_remote_reachable(&host) {
ExitReachability::Remote
} else {
ExitReachability::LocalOnly {
host,
source: "proxy",
}
}
}
}
}
/// Classify a WireGuard peer endpoint (`host:port`).
pub fn classify_wireguard_endpoint(peer_endpoint: &str) -> ExitReachability {
let host = normalize_host(peer_endpoint);
if host.is_empty() {
return ExitReachability::Unknown {
reason: "VPN config has no peer endpoint".to_string(),
source: "VPN",
};
}
if host_is_remote_reachable(&host) {
ExitReachability::Remote
} else {
ExitReachability::LocalOnly {
host,
source: "VPN",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::browser::ProxySettings;
fn proxy(proxy_type: &str, host: &str) -> ProxySettings {
ProxySettings {
proxy_type: proxy_type.to_string(),
host: host.to_string(),
port: 8080,
username: None,
password: None,
vless_uri: None,
}
}
#[test]
fn loopback_in_every_spelling_is_local() {
// The literal case the bug was reported for, plus the spellings that reach
// the same place. `::ffff:127.0.0.1` is the one a naive IPv6 check misses.
for host in [
"127.0.0.1",
"127.1.2.3",
"localhost",
"LOCALHOST",
"foo.localhost",
"::1",
"[::1]",
"::ffff:127.0.0.1",
"0.0.0.0",
"::",
] {
assert!(
!host_is_remote_reachable(host),
"{host} should not be remote-reachable"
);
}
}
#[test]
fn private_and_carrier_ranges_are_local() {
for host in [
"10.0.0.1",
"192.168.1.1",
"172.16.0.1",
"172.31.255.254",
"169.254.1.1", // link-local / APIPA
"100.64.0.1", // CGNAT
"100.127.255.1",
"fd00::1", // unique-local
"fe80::1", // link-local
"240.0.0.1",
"0.1.2.3",
] {
assert!(
!host_is_remote_reachable(host),
"{host} should not be remote-reachable"
);
}
}
#[test]
fn public_addresses_and_names_are_reachable() {
for host in [
"1.1.1.1",
"8.8.8.8",
"172.15.0.1", // just outside 172.16/12
"172.32.0.1",
"100.63.255.255", // just outside 100.64/10
"100.128.0.1",
"2606:4700:4700::1111",
"proxy.example.com",
"gate.smartproxy.net.",
"residential.example.co.uk",
] {
assert!(
host_is_remote_reachable(host),
"{host} should be remote-reachable"
);
}
}
#[test]
fn lan_only_names_are_local() {
// A fleet host resolving these gets ITS network's answer, not the user's —
// which is worse than failing, because it may well succeed against
// something unrelated.
for host in [
"my-proxy", // single label: needs a search domain
"router.local",
"nas.home.arpa",
"proxy.lan",
"box.internal",
"server.localdomain",
"gateway.intranet",
] {
assert!(
!host_is_remote_reachable(host),
"{host} should not be remote-reachable"
);
}
}
#[test]
fn host_port_and_credentials_are_stripped_before_classifying() {
assert!(!host_is_remote_reachable("127.0.0.1:8080"));
assert!(!host_is_remote_reachable("user:pass@127.0.0.1:8080"));
assert!(!host_is_remote_reachable("[::1]:1080"));
assert!(host_is_remote_reachable("user:p@ss@proxy.example.com:8080"));
}
#[test]
fn a_vless_proxy_is_judged_by_its_server_not_its_local_port() {
// THE asymmetry. Donut points the browser at a local xray worker, so the
// browser-facing address of every VLESS proxy is 127.0.0.1 — but the stored
// config names a real server, and that is what a fleet host would dial.
// Classifying VLESS off `settings.host` would refuse every VLESS profile.
let mut settings = proxy("vless", "127.0.0.1");
settings.vless_uri =
Some("vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@vpn.example.com:443?type=tcp#node".into());
assert_eq!(classify_proxy(&settings), ExitReachability::Remote);
}
#[test]
fn a_vless_uri_pointing_at_loopback_is_still_local() {
let mut settings = proxy("vless", "127.0.0.1");
settings.vless_uri = Some("vless://uuid@127.0.0.1:443?type=tcp".into());
assert_eq!(
classify_proxy(&settings),
ExitReachability::LocalOnly {
host: "127.0.0.1".to_string(),
source: "proxy",
}
);
}
#[test]
fn vless_host_parsing_survives_query_and_fragment() {
assert_eq!(
vless_uri_host("vless://uuid@example.com:443?sni=a@b.com&x=1#my@label"),
Some("example.com".to_string())
);
assert_eq!(
vless_uri_host("vless://uuid@[2606:4700::1111]:443?type=ws"),
Some("2606:4700::1111".to_string())
);
assert_eq!(vless_uri_host("not-a-vless-uri"), None);
}
#[test]
fn an_unreadable_config_fails_closed() {
// Unknown must never be treated as usable: the point of the check is that
// we could not confirm reachability, and guessing "yes" reintroduces the
// exact failure it prevents.
let mut settings = proxy("vless", "");
settings.vless_uri = None;
let verdict = classify_proxy(&settings);
assert!(matches!(verdict, ExitReachability::Unknown { .. }));
assert!(!verdict.is_remote());
assert!(verdict.refusal_detail().is_some());
}
#[test]
fn ordinary_proxies_are_classified_by_host() {
assert_eq!(
classify_proxy(&proxy("socks5", "gate.example.com")),
ExitReachability::Remote
);
assert_eq!(
classify_proxy(&proxy("http", "192.168.0.10")),
ExitReachability::LocalOnly {
host: "192.168.0.10".to_string(),
source: "proxy",
}
);
}
#[test]
fn wireguard_endpoints_are_classified_by_their_peer() {
assert_eq!(
classify_wireguard_endpoint("vpn.example.com:51820"),
ExitReachability::Remote
);
assert_eq!(
classify_wireguard_endpoint("10.0.0.1:51820"),
ExitReachability::LocalOnly {
host: "10.0.0.1".to_string(),
source: "VPN",
}
);
assert!(matches!(
classify_wireguard_endpoint(" "),
ExitReachability::Unknown { .. }
));
}
#[test]
fn only_remote_permits_a_run() {
assert!(ExitReachability::Remote.is_remote());
assert!(!ExitReachability::None.is_remote());
assert!(!ExitReachability::LocalOnly {
host: "127.0.0.1".into(),
source: "proxy"
}
.is_remote());
// `None` has no detail: the caller's existing "no exit at all" refusal is
// the better message, and two refusals for one condition read as a bug.
assert!(ExitReachability::None.refusal_detail().is_none());
}
}
+101
View File
@@ -8,6 +8,7 @@
use crate::cloud_errors::{self, FailureCodes};
use crate::profile::types::BrowserProfile;
use crate::remote_exit::ExitReachability;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -115,6 +116,54 @@ pub fn idempotency_key(profile_id: &str, attempt: &str) -> String {
format!("run-remote:{profile_id}:{attempt}")
}
/// Whether this profile's exit rules out running it on a leased host.
///
/// A session runs on a fleet host that pulls the profile — and its proxy record
/// — out of the user's sync namespace, rewriting no addresses along the way. A
/// proxy stored as `127.0.0.1:8080` therefore arrives meaning THAT host's
/// loopback: the browser either cannot connect and the leased hour is burned on
/// a session that never worked, or it falls through and the user's identity
/// egresses from our datacenter. The Cookie Bot has refused this since
/// `remote_exit` existed; interactive sessions take the same profile onto the
/// same hosts and did not, so the same mistake cost a leased hour here.
///
/// A profile with NO exit at all is deliberately allowed through. The Cookie
/// Bot refuses that separately because a night of unattended browsing from a
/// hosting ASN damages an identity, but an interactive session is a person at a
/// keyboard who chose to open this profile and can see where it comes out —
/// and no rule has ever required an exit here. Refusing it would be a new
/// product restriction wearing this bug's error code.
///
/// Split out from the launch because that is the only testable seam:
/// `exit_reachability` reads this machine's proxy and VPN stores and the launch
/// itself needs a fleet.
fn local_exit_refusal(verdict: &ExitReachability) -> Option<RemoteSessionError> {
match verdict {
// An address anyone can dial, so the leased host can dial it too.
ExitReachability::Remote => None,
// See the second paragraph above: allowed on purpose, not overlooked.
ExitReachability::None => None,
// `LocalOnly`, plus `Unknown` — which `remote_exit` produces when it could
// not read the config and which fails closed by design, because "we could
// not confirm it" guessed as "yes" is the failure this whole check exists
// to stop.
unusable => {
// The prose names the offending host, which belongs in the log where
// support can read it. The toast gets the code so it stays translated.
if let Some(detail) = unusable.refusal_detail() {
log::warn!("Refusing an interactive remote session: {detail}");
}
// `Other` rather than a typed variant: the other three are each pinned to
// a status and a meaning — "the fleet is busy", "already open somewhere",
// "not on your plan" — and this refusal is none of them. The code in the
// body is what every surface renders.
Some(RemoteSessionError::Other(
serde_json::json!({ "code": "REMOTE_REQUIRES_REMOTE_EXIT_NODE" }).to_string(),
))
}
}
}
/// Ask donutbrowser-infra to start a remote session for this profile.
///
/// Goes through `api_call_with_retry` so an expired access token is refreshed
@@ -133,6 +182,14 @@ pub async fn start_remote_session(
.to_string();
let profile_id = profile.id.to_string();
// Checked here, before the request: the backend is told which profile to
// start but never sees the proxy record, so it cannot derive this — and by
// the time it could, an hour is already leased and billed. Resolving a proxy
// id to an address is only possible on the machine that stores it.
if let Some(refusal) = local_exit_refusal(&crate::cookie_bot::exit_reachability(profile)) {
return Err(refusal);
}
// One key for this user action: a retry inside api_call_with_retry must
// de-duplicate rather than open a second browser on the same profile.
let key = idempotency_key(&profile_id, &uuid::Uuid::new_v4().to_string());
@@ -1180,6 +1237,50 @@ mod tests {
);
}
#[test]
fn a_local_only_exit_is_refused_before_a_host_is_leased() {
// The profile and its proxy record are copied onto the fleet unrewritten,
// so this loopback address would mean the FLEET's loopback. Accepting the
// launch bills an hour for a session that cannot reach the user's exit.
let refusal = local_exit_refusal(&ExitReachability::LocalOnly {
host: "127.0.0.1".to_string(),
source: "proxy",
})
.expect("a loopback proxy is unusable from a leased host");
assert_eq!(
refusal.to_error_json(),
r#"{"code":"REMOTE_REQUIRES_REMOTE_EXIT_NODE"}"#
);
}
#[test]
fn an_exit_that_could_not_be_read_is_refused_too() {
// `Unknown` is "we could not confirm this works from elsewhere". Treating
// that as a yes reintroduces exactly the burned hour above, so it fails
// closed here as it does everywhere else `remote_exit` is consulted.
let refusal = local_exit_refusal(&ExitReachability::Unknown {
reason: "the profile references a proxy that no longer exists".to_string(),
source: "proxy",
})
.expect("an unreadable exit is not evidence of a reachable one");
assert_eq!(
refusal.to_error_json(),
r#"{"code":"REMOTE_REQUIRES_REMOTE_EXIT_NODE"}"#
);
}
#[test]
fn a_reachable_exit_and_no_exit_at_all_are_both_allowed_to_launch() {
assert!(local_exit_refusal(&ExitReachability::Remote).is_none());
// Deliberate, and the reason this gate is not simply `!is_remote()`: a
// proxyless interactive session has always been permitted, and refusing it
// with a code that says "your proxy is local" would be both a new product
// rule and a sentence that does not describe the profile.
assert!(local_exit_refusal(&ExitReachability::None).is_none());
}
#[test]
fn a_backend_supplied_code_survives_the_trip_through_the_typed_error() {
// Once infra sends an envelope, its code must win over the status default
@@ -22,6 +22,9 @@ const MAX_EXTENSION_DIRS: usize = 300;
const MAX_MANIFEST_BYTES: u64 = 512 * 1024;
/// Wall-clock ceiling for the whole scan. This runs on the launch path.
const SCAN_DEADLINE: Duration = Duration::from_millis(750);
/// Chromium preference files are larger than a manifest but still bounded; a
/// pathological one must not be parsed while the user waits for a browser.
const MAX_PREFERENCES_BYTES: u64 = 32 * 1024 * 1024;
/// Chromium profile directories to search inside a user-data dir.
///
@@ -69,6 +72,19 @@ fn read_json_file(path: &Path, max_bytes: Option<u64>) -> Option<serde_json::Val
serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
}
/// Chromium locale directory names are `[A-Za-z0-9_-]` (`en`, `en_GB`,
/// `zh_CN`). Anything else in a manifest we did not write is untrusted input
/// being joined into a filesystem path, so it is refused rather than sanitized
/// — this runs on the launch path against extensions the user may have
/// sideloaded.
fn is_safe_locale_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 32
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
fn resolve_dir_i18n(
version_dir: &Path,
manifest: &serde_json::Value,
@@ -76,6 +92,10 @@ fn resolve_dir_i18n(
) -> Option<String> {
let key = message_placeholder_key(value)?;
let default_locale = manifest.get("default_locale")?.as_str()?;
if !is_safe_locale_name(default_locale) {
log::warn!("Ignoring extension with a suspicious default_locale: {default_locale:?}");
return None;
}
let messages = read_json_file(
&version_dir
.join("_locales")
@@ -105,8 +125,9 @@ struct PreferenceExtensions {
fn preference_extensions(profile_dir: &Path) -> PreferenceExtensions {
let mut out = PreferenceExtensions::default();
for file in ["Secure Preferences", "Preferences"] {
// Preference files are legitimately large, so they skip the manifest cap.
let Some(prefs) = read_json_file(&profile_dir.join(file), None) else {
// Larger than a manifest, but still capped: this is parsed while the user
// waits for a browser to start.
let Some(prefs) = read_json_file(&profile_dir.join(file), Some(MAX_PREFERENCES_BYTES)) else {
continue;
};
let Some(settings) = prefs
@@ -150,6 +171,9 @@ pub(super) fn scan_browser_extensions(
// Read preferences first: unpacked extensions live outside Extensions/, so
// a profile that has only sideloaded ones has no Extensions/ dir at all and
// must not be skipped before they are considered.
if started.elapsed() > SCAN_DEADLINE {
return false;
}
let prefs = preference_extensions(&profile_dir);
let disabled = &prefs.disabled;
@@ -566,6 +590,41 @@ mod tests {
assert!(out.is_empty());
}
#[test]
fn a_traversing_default_locale_is_refused() {
// `default_locale` comes from a manifest we did not write. Joined naively
// it reads any file the user can read, on the launch path.
assert!(!is_safe_locale_name("../../../../etc"));
assert!(!is_safe_locale_name("..\\..\\windows"));
assert!(!is_safe_locale_name("/etc/passwd"));
assert!(!is_safe_locale_name(""));
assert!(is_safe_locale_name("en"));
assert!(is_safe_locale_name("en_GB"));
assert!(is_safe_locale_name("zh-CN"));
}
#[test]
fn a_localized_name_with_a_traversing_locale_is_not_resolved() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let version_dir = root
.join("Default")
.join("Extensions")
.join(CRX_ID)
.join("2.1.0_0");
write(
&version_dir.join("manifest.json"),
r#"{"name":"__MSG_appName__","version":"2.1.0","default_locale":"../../../../etc","permissions":["proxy"]}"#,
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
// Still detected (the proxy permission is what matters), but the name
// falls back rather than the traversal being followed.
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, CRX_ID);
}
#[test]
fn scan_tolerates_a_missing_user_data_dir() {
let tmp = tempfile::tempdir().unwrap();
+6 -7
View File
@@ -142,14 +142,13 @@ pub fn scan_profile(profile: &BrowserProfile) -> ExtensionScan {
"partial"
};
// The two sources are disjoint by construction, but an imported profile can
// carry its own copy of an extension Donut also manages. Collapse only on an
// exact name+version match, and never on an unresolved `__MSG_` placeholder
// those are not identities and would fold unrelated extensions into one row.
// Collapse only exact duplicates of the same extension. `key` is the real
// identity (`donut:<uuid>` / `crx:<id>`); name+version is not, and two
// distinct extensions sharing a display name would silently fold into one
// dropping a `confirmed` detection would then flip `has_confirmed()` and stop
// the gate treating its own exit measurement as unreliable.
let mut seen = HashSet::new();
extensions.retain(|e| {
message_placeholder_key(&e.name).is_some() || seen.insert((e.name.clone(), e.version.clone()))
});
extensions.retain(|e| seen.insert(e.key.clone()));
ExtensionScan {
extensions,
+182
View File
@@ -0,0 +1,182 @@
//! Client-side window decorations on Linux.
//!
//! The app draws its own titlebar (as it already does on macOS and Windows), so
//! the window is built without server-side decorations. That means the app also
//! owns the window *controls*, and their side and order are a desktop-wide user
//! preference that differs between environments — GNOME defaults to
//! `:minimize,maximize,close` (all on the right), and a user who has moved them
//! to the left expects every app to follow.
//!
//! `GtkSettings::gtk-decoration-layout` is the one place every desktop
//! publishes that preference to GTK applications: GNOME mirrors
//! `org.gnome.desktop.wm.preferences button-layout` into it, and on KDE Plasma
//! `kde-gtk-config` mirrors KWin's decoration button configuration into it.
//! Reading this property therefore gets both environments right without any
//! desktop-specific branching.
use serde::Serialize;
/// Whether this window draws its own titlebar, and how.
#[derive(Debug, Clone, Serialize)]
pub struct WindowDecorations {
/// True when the app owns the titlebar and must draw controls and resize
/// edges. False means the platform still draws a real titlebar and the
/// frontend must render nothing.
pub client_side: bool,
/// The desktop's button layout, e.g. `":minimize,maximize,close"`. Only
/// meaningful when `client_side` is true.
pub layout: Option<String>,
}
/// Whether to drop server-side decorations on this Linux session.
///
/// Enabled everywhere except KDE Plasma on Wayland, and overridable with
/// `DONUT_LINUX_CLIENT_DECORATIONS=1|0`.
///
/// The KDE/Wayland exclusion is deliberate and is about a failure mode, not a
/// preference. GTK3 speaks no `xdg-decoration`; when a window is built
/// undecorated, GTK does not mark it client-decorated, and on Wayland it
/// therefore *announces server-side decorations* to the compositor. mutter
/// ignores that (it never decorates Wayland toplevels), which is why GNOME
/// works. KWin honors it, so Plasma would be free to draw a Breeze titlebar
/// directly above the one the app draws — two titlebars, worse than the
/// feature is good. Whether it actually does depends on the decoration mode
/// KWin advertises, which could not be established from documentation and
/// cannot be tested from here, so this stays off until somebody can run it.
///
/// KDE on X11 is *not* excluded: there the request travels as `_MOTIF_WM_HINTS`,
/// which KWin has honored for as long as it has existed.
#[cfg(target_os = "linux")]
pub fn use_client_side_decorations() -> bool {
if let Ok(value) = std::env::var("DONUT_LINUX_CLIENT_DECORATIONS") {
let forced = matches!(value.trim(), "1" | "true" | "yes");
log::info!("Client-side decorations forced to {forced} by DONUT_LINUX_CLIENT_DECORATIONS");
return forced;
}
let env = |key: &str| std::env::var(key).unwrap_or_default().to_lowercase();
// GDK_BACKEND is a comma-separated preference list ("wayland,x11"), and GDK
// takes the FIRST entry it can open. Testing for a substring would read
// "wayland,x11" as X11 and hand a Plasma Wayland session the undecorated
// path this guard exists to withhold.
let backend = env("GDK_BACKEND");
let preferred = backend
.split(',')
.map(str::trim)
.find(|value| !value.is_empty());
let on_wayland = match preferred {
Some("x11") => false,
Some("wayland") => true,
// Unset or something exotic: fall back to what the session advertises.
_ => {
!std::env::var("WAYLAND_DISPLAY")
.unwrap_or_default()
.is_empty()
&& env("XDG_SESSION_TYPE") != "x11"
}
};
let on_kde = env("XDG_CURRENT_DESKTOP").contains("kde")
|| env("XDG_SESSION_DESKTOP").contains("plasma")
|| env("DESKTOP_SESSION").contains("plasma")
|| !std::env::var("KDE_FULL_SESSION")
.unwrap_or_default()
.is_empty();
if on_kde && on_wayland {
log::info!(
"Keeping server-side decorations: KWin on Wayland may draw its own titlebar over the \
app's. Set DONUT_LINUX_CLIENT_DECORATIONS=1 to override."
);
return false;
}
true
}
#[cfg(target_os = "linux")]
mod imp {
use std::sync::Mutex;
lazy_static::lazy_static! {
static ref LAYOUT: Mutex<Option<String>> = Mutex::new(None);
}
fn store(layout: Option<String>) {
if let Ok(mut slot) = LAYOUT.lock() {
*slot = layout;
}
}
pub fn cached() -> Option<String> {
LAYOUT.lock().ok().and_then(|slot| slot.clone())
}
/// Read the layout and subscribe to changes.
///
/// MUST be called on the GTK main thread — `gtk::Settings::default()` panics
/// elsewhere, and the notify subscription has to be attached from the thread
/// owning the GTK main context. The app's `setup` hook already runs there.
pub fn init<R: tauri::Runtime>(app: &tauri::AppHandle<R>) {
use gtk::prelude::*;
use tauri::Emitter;
let Some(settings) = gtk::Settings::default() else {
log::warn!("No GTK settings available; using the default decoration layout");
return;
};
store(settings.gtk_decoration_layout().map(|v| v.to_string()));
log::info!(
"Window decoration layout: {}",
cached().as_deref().unwrap_or("<unset>")
);
// The user can rearrange titlebar buttons while the app is running, and
// every other application follows immediately. Note this never fires where
// the value comes only from gtk-3.0/settings.ini (a KDE X11 box with no
// xsettings daemon) — there it is simply static until restart.
let handle = app.clone();
settings.connect_gtk_decoration_layout_notify(move |settings| {
let layout = settings.gtk_decoration_layout().map(|v| v.to_string());
log::info!(
"Window decoration layout changed to: {}",
layout.as_deref().unwrap_or("<unset>")
);
store(layout.clone());
if let Err(e) = handle.emit("window-decoration-layout-changed", layout) {
log::warn!("Failed to emit window decoration layout change: {e}");
}
});
}
}
#[cfg(not(target_os = "linux"))]
mod imp {
pub fn init<R: tauri::Runtime>(_app: &tauri::AppHandle<R>) {}
}
pub use imp::init;
/// How this window is decorated, and the desktop's button layout when the app
/// owns the titlebar.
#[tauri::command]
pub fn get_window_decoration_layout() -> WindowDecorations {
#[cfg(target_os = "linux")]
{
let client_side = use_client_side_decorations();
WindowDecorations {
client_side,
layout: if client_side { imp::cached() } else { None },
}
}
// Every other platform keeps a real titlebar: macOS makes the native one
// transparent, Windows draws its own controls on a fixed layout.
#[cfg(not(target_os = "linux"))]
{
WindowDecorations {
client_side: false,
layout: None,
}
}
}
+39
View File
@@ -27,3 +27,42 @@ pub enum XrayError {
#[error("failed to serialize Xray client configuration")]
Serialization,
}
impl XrayError {
/// A stable, translatable identifier for *why* a URI was rejected.
///
/// Donut supports one VLESS shape — REALITY + XTLS Vision over TCP — so most
/// rejections are "your setup is a kind we do not support", not "you made a
/// typo". The frontend turns these into a sentence naming the unsupported
/// part; without them every rejection reads as a malformed URI and a user
/// with a working WebSocket or plain-TLS server has no idea why it failed.
pub fn reason_code(&self) -> &'static str {
match self {
Self::UnsupportedScheme => "scheme",
Self::UnsupportedValue { field, .. } | Self::InvalidField { field, .. } => match *field {
"security" => "security",
"flow" => "flow",
"type" => "transport",
"encryption" => "encryption",
"headerType" => "headerType",
"fp" => "fingerprint",
// A malformed sni/public key is the same user-facing problem as a
// missing one, so it earns the same specific help rather than the
// generic "invalid URI".
"sni" | "server_name" => "sni",
"pbk" | "public_key" => "publicKey",
_ => "malformed",
},
Self::MissingField(field) => match *field {
"sni" => "sni",
"pbk" => "publicKey",
"security" => "security",
"flow" => "flow",
_ => "malformed",
},
Self::UnsupportedParameter(_) => "parameter",
Self::DuplicateParameter(_) => "malformed",
Self::InvalidUri | Self::Serialization => "malformed",
}
}
}
+133 -8
View File
@@ -61,10 +61,10 @@ pub fn parse_vless_uri(input: &str) -> XrayResult<ParsedVlessUri> {
};
let port = url.port().ok_or(XrayError::MissingField("port"))?;
let parameters = parse_parameters(&url)?;
require_value(&parameters, "security", "reality")?;
require_value(&parameters, "flow", VlessFlow::Vision.as_str())?;
optional_value(&parameters, "encryption", "none")?;
let (parameters, unsupported) = parse_parameters(&url)?;
// Transport first: it is the most common reason a real-world VLESS server is
// unusable here, and it explains the stray parameters that come with it.
match parameters.get("type").map(String::as_str) {
None | Some("tcp" | "raw") => {}
Some(_) => {
@@ -74,8 +74,17 @@ pub fn parse_vless_uri(input: &str) -> XrayResult<ParsedVlessUri> {
});
}
}
require_value(&parameters, "security", "reality")?;
require_value(&parameters, "flow", VlessFlow::Vision.as_str())?;
optional_value(&parameters, "encryption", "none")?;
optional_value(&parameters, "headerType", "none")?;
// Only once the shape is known-good does an unrecognized parameter become
// the most useful thing to report.
if let Some(name) = unsupported.into_iter().next() {
return Err(XrayError::UnsupportedParameter(name));
}
let server_name = required_parameter(&parameters, "sni")?.to_string();
let public_key = required_parameter(&parameters, "pbk")?.to_string();
let short_id = parameters.get("sid").cloned().unwrap_or_default();
@@ -162,15 +171,28 @@ pub fn export_vless_uri(config: &VlessRealityConfig, name: Option<&str>) -> Xray
query.append_pair("type", "tcp");
query.append_pair("headerType", "none");
}
url.set_fragment(name);
// The parser percent-DECODES the fragment, so the exporter must encode it or
// a name containing `%` (or `#`) comes back different every time the URI is
// canonicalized — the name mutates a little more on each save.
let encoded_name = name.map(|value| urlencoding::encode(value).into_owned());
url.set_fragment(encoded_name.as_deref());
Ok(url.into())
}
fn parse_parameters(url: &Url) -> XrayResult<HashMap<String, String>> {
/// Split the query into recognized parameters and the names of the rest.
///
/// Unrecognized names are returned rather than rejected on the spot so the
/// caller can report the *shape* problem first. A WebSocket URI always carries
/// `path` (and usually `host`), gRPC carries `serviceName` — naming those keys
/// instead of the transport sends the user deleting parameters when the real
/// answer is that Donut only speaks plain TCP.
fn parse_parameters(url: &Url) -> XrayResult<(HashMap<String, String>, Vec<String>)> {
let mut parameters = HashMap::new();
let mut unsupported = Vec::new();
for (name, value) in url.query_pairs() {
if !SUPPORTED_PARAMETERS.contains(&name.as_ref()) {
return Err(XrayError::UnsupportedParameter(name.into_owned()));
unsupported.push(name.into_owned());
continue;
}
if parameters
.insert(name.to_string(), value.into_owned())
@@ -179,7 +201,7 @@ fn parse_parameters(url: &Url) -> XrayResult<HashMap<String, String>> {
return Err(XrayError::DuplicateParameter(name.into_owned()));
}
}
Ok(parameters)
Ok((parameters, unsupported))
}
fn required_parameter<'a>(
@@ -230,6 +252,109 @@ mod tests {
const ID: &str = "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4";
/// Donut accepts exactly one VLESS shape, so most rejections mean "your
/// server is a kind we do not support" rather than "you mistyped". These pin
/// the reason each rejection reports, because the UI turns it into the one
/// sentence that tells a user with a working WebSocket or plain-TLS server
/// why Donut will not take it.
#[test]
fn unsupported_setups_report_which_part_is_unsupported() {
let good = format!(
"vless://{ID}@example.com:443?security=reality&flow=xtls-rprx-vision\
&encryption=none&type=tcp&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4&sid=00&fp=chrome"
);
assert!(parse_vless_uri(&good).is_ok(), "baseline URI must parse");
let reason = |uri: &str| parse_vless_uri(uri).unwrap_err().reason_code();
// Plain TLS instead of REALITY — the most common real-world setup.
assert_eq!(
reason(&good.replace("security=reality", "security=tls")),
"security"
);
assert_eq!(
reason(&good.replace("flow=xtls-rprx-vision", "flow=none")),
"flow"
);
// WebSocket / gRPC transports.
assert_eq!(reason(&good.replace("type=tcp", "type=ws")), "transport");
assert_eq!(reason(&good.replace("type=tcp", "type=grpc")), "transport");
assert_eq!(reason(&good.replace("&sni=a.com", "")), "sni");
assert_eq!(
reason(&good.replace("&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4", "")),
"publicKey"
);
assert_eq!(reason(&good.replace("vless://", "vmess://")), "scheme");
assert_eq!(reason("not a uri"), "malformed");
}
/// The URIs users actually paste, not canonical-REALITY-with-one-field-changed.
///
/// A real WebSocket link carries `path` (and usually `host`); a gRPC link
/// carries `serviceName`. Those keys are not in SUPPORTED_PARAMETERS, so
/// before the shape was checked first they produced "unsupported option"
/// and sent the user deleting query parameters instead of telling them
/// Donut only speaks plain TCP.
#[test]
fn a_display_name_survives_an_export_parse_round_trip() {
// Percent signs are legal in a fragment, so they used to pass through
// unencoded and then get decoded on the way back in — "50% off" became
// "50 off"-ish and drifted further on every canonicalizing save.
for name in ["50% off", "a#b", "spaced name", "100%25", "üñî"] {
let parsed = parse_vless_uri(&format!(
"vless://{ID}@example.com:443?security=reality&flow=xtls-rprx-vision\
&encryption=none&type=tcp&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4"
))
.expect("baseline parses");
let exported = export_vless_uri(&parsed.config, Some(name)).expect("exports");
let reparsed = parse_vless_uri(&exported).expect("re-parses");
assert_eq!(
reparsed.name.as_deref(),
Some(name),
"display name mutated across a round trip: {exported}"
);
// And a second round trip must be a fixed point, not drift again.
let exported_again =
export_vless_uri(&reparsed.config, reparsed.name.as_deref()).expect("re-exports");
assert_eq!(exported, exported_again);
}
}
#[test]
fn real_world_websocket_and_grpc_links_name_the_transport() {
let ws = format!(
"vless://{ID}@cdn.example.com:443?encryption=none&security=tls&type=ws\
&path=%2Fray&host=cdn.example.com&sni=cdn.example.com#WS%20node"
);
assert_eq!(
parse_vless_uri(&ws).unwrap_err().reason_code(),
"transport",
"a WebSocket link must be told its transport is unsupported"
);
let grpc = format!(
"vless://{ID}@grpc.example.com:443?encryption=none&security=reality&type=grpc\
&serviceName=gun&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4"
);
assert_eq!(
parse_vless_uri(&grpc).unwrap_err().reason_code(),
"transport"
);
// A genuinely unknown option on an otherwise-supported URI still reports
// as a parameter problem, which is the accurate answer there.
let odd = format!(
"vless://{ID}@example.com:443?security=reality&flow=xtls-rprx-vision\
&encryption=none&type=tcp&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4&madeUpKey=1"
);
assert_eq!(
parse_vless_uri(&odd).unwrap_err().reason_code(),
"parameter"
);
}
fn public_key() -> String {
URL_SAFE_NO_PAD.encode([7_u8; 32])
}
+3 -3
View File
@@ -165,7 +165,7 @@ pub async fn start_xray_worker(
) -> Result<XrayWorkerConfig, Box<dyn std::error::Error>> {
let _start_guard = XRAY_START_LOCK.lock().await;
parse_vless_uri(vless_uri)
.map_err(|error| structured_error_with_detail("VLESS_CONFIG_INVALID", error))?;
.map_err(|error| -> Box<dyn std::error::Error> { crate::vless_config_error(&error).into() })?;
crate::proxy_runner::ensure_sidecar_version().await?;
ensure_xray_binary()?;
let owner_pid = std::process::id();
@@ -521,14 +521,14 @@ pub async fn run_xray_worker(config_path: &Path) -> Result<(), Box<dyn std::erro
save_xray_worker_config_to_path(&config, config_path)
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
let parsed = parse_vless_uri(&config.vless_uri)
.map_err(|error| structured_error_with_detail("VLESS_CONFIG_INVALID", error))?;
.map_err(|error| -> Box<dyn std::error::Error> { crate::vless_config_error(&error).into() })?;
let runtime = XrayClientRuntime {
listen_port: config.local_port,
username: config.username.clone(),
password: config.password.clone(),
};
let runtime_json = build_client_config_json(&parsed.config, &runtime)
.map_err(|error| structured_error_with_detail("VLESS_CONFIG_INVALID", error))?;
.map_err(|error| -> Box<dyn std::error::Error> { crate::vless_config_error(&error).into() })?;
write_xray_runtime_config(&config.id, runtime_json.as_bytes())
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
let runtime_path = crate::xray_worker_storage::xray_runtime_config_path(&config.id);
+35
View File
@@ -182,6 +182,39 @@ fn worker_is_tombstoned(id: &str) -> bool {
xray_worker_tombstone_path(id).exists()
}
/// How long a tombstone has to outlive its worker.
///
/// It only has to survive long enough to beat a write already in flight from
/// the process that owned that id. A day is many orders of magnitude more than
/// that, and bounds a directory that otherwise gains a file per worker forever.
const TOMBSTONE_TTL: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
/// Drop tombstones old enough that nothing could still be racing them.
fn prune_stale_tombstones() {
let Ok(entries) = fs::read_dir(crate::proxy_storage::get_storage_dir()) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("stopped") {
continue;
}
let aged_out = path
.metadata()
.and_then(|meta| meta.modified())
.map(|modified| {
modified
.elapsed()
.map(|age| age > TOMBSTONE_TTL)
.unwrap_or(false)
})
.unwrap_or(false);
if aged_out {
let _ = fs::remove_file(&path);
}
}
}
pub fn create_xray_worker_log(id: &str) -> std::io::Result<std::fs::File> {
ensure_private_storage_dir()?;
if worker_is_tombstoned(id) {
@@ -275,6 +308,8 @@ pub fn delete_xray_worker_config(id: &str) -> bool {
}
pub fn list_xray_worker_configs() -> Vec<XrayWorkerConfig> {
// Cheap, and this is the one call every sweep already makes.
prune_stale_tombstones();
let storage_dir = crate::proxy_storage::get_storage_dir();
let Ok(entries) = fs::read_dir(storage_dir) else {
return Vec::new();