Files
Triple-C/app/src-tauri/src/url_open.rs
T
shadowdaoandClaude Opus 5 943c83b9e3
Secret Scan / scan (push) Successful in 3s
Build App (Preview) / compute-version (pull_request) Successful in 7s
Secret Scan / scan (pull_request) Successful in 5s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / build-macos (pull_request) Successful in 2m48s
Build App (Preview) / build-windows (pull_request) Successful in 4m55s
Build App (Preview) / build-linux (pull_request) Successful in 8m39s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
fix: stop a stale payload re-enabling a bridge the user turned off
Review of this branch found that `update_project` restored
`browser_view_enabled` from the store but took `auth_bridge_enabled` from
the IPC payload, on a comment claiming the Config tab edits it through that
save. The comment was wrong. `AuthBridgeRow` is the only writer, it calls
`set_auth_bridge_enabled` out of band precisely so the switch works while a
login is hanging, and it never writes the value back into frontend state --
so a payload's copy of that flag is always a stale snapshot.

The consequence was not cosmetic: turn the bridge off, then close a renamed
terminal tab, and `useTerminal` round-trips the stale `true` and the
reconcile block restarts a bridge whose own UI warns that a bridged port is
unauthenticated and reachable by any local process. Defaulting the flag to
true earlier in this branch made it worse, since the stale value is now
true for every pre-existing project.

Both flags are now restored from the store by `restore_store_owned_fields`,
and the reconcile block is gone rather than corrected: with the value
always restored it could only re-assert what was already true, and every
writer already owns its own side effect -- the setter starts and stops
synchronously, container start arms the bridge, launch reconcile re-arms
it, and the poller re-reads the flag each tick and self-terminates.
Re-adding a start path to the one function that no longer owns the flag is
what caused this.

Turning the browser view off also stopped tearing the session down when the
project record had vanished, because the persist used `?` and returned
early -- the supervisor's own `store.get()` check exists because records do
vanish mid-session. Teardown is now unconditional and the write error still
surfaces afterwards, since the stored flag saying "enabled" means the view
returns on next launch and that is worth reporting.

Finally, the opener no longer falls through to `gio` on any non-zero exit.
xdg-open's 1, 2 and 3 assert no handler ran; 4 also covers a handler that
was launched and then failed, which would have opened the link twice --
two authorize requests for one click in an OAuth flow. Reasoned from
documented exit codes rather than an observed double-open, and the cost is
stated: a genuine code-4 failure no longer reaches gio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 11:13:57 -07:00

792 lines
32 KiB
Rust

//! Opening a URL in the *host's* browser — the half of triple-c#34 where
//! "Open" appeared to do nothing on Linux.
//!
//! # Why this module exists rather than `openUrl` from `@tauri-apps/plugin-opener`
//!
//! The plugin's Linux path shells out to `xdg-open`, and the child inherits
//! this process's environment verbatim. Inside an AppImage that environment is
//! not the user's — it is the AppImage's, and it is actively hostile to any
//! program that is not the one the bundle was built for:
//!
//! - linuxdeploy's `AppRun`/`AppRun.wrapped` prepends the bundle's own
//! directories to `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `PYTHONPATH`,
//! `PERLLIB`, `QT_PLUGIN_PATH` and `GSETTINGS_SCHEMA_DIR`.
//! - `linuxdeploy-plugin-gtk`'s hook adds `GTK_PATH`, `GTK_EXE_PREFIX`,
//! `GTK_DATA_PREFIX`, `GTK_IM_MODULE_FILE`, `GIO_MODULE_DIR` and
//! `GDK_PIXBUF_MODULE_FILE`.
//! - `scripts/finalize-appimage.sh` installs one more hook of our own
//! (`triple-c-wayland-fallback.sh`) that can prepend
//! `$APPDIR/usr/lib/wayland-fallback` to `LD_LIBRARY_PATH`.
//! - `main.rs` sets `WEBKIT_DISABLE_DMABUF_RENDERER` process-wide, and the
//! comment there has flagged this leak for a while: it reaches whatever the
//! app spawns afterwards.
//!
//! A browser that is *already running* is unaffected — `xdg-open` just hands
//! the URL to the existing instance over D-Bus/IPC and the new process exits.
//! A **cold-launched** browser loads our bundled GTK/glib/pixbuf stack against
//! the host's, aborts before it ever paints, and `xdg-open` has already
//! returned 0. From the app's point of view the click did nothing. That is the
//! reported symptom, and it is why the bug only reproduces for some people.
//!
//! # What this does instead
//!
//! `open_url_external` re-validates the URL (see below) and spawns the opener
//! with a **sanitized child environment**. Sanitizing is
//! [`sanitize_child_env`], a pure function over two maps so it can be tested
//! without touching process-wide state:
//!
//! 1. If the AppImage saved the pre-launch value under a `*_ORIG` /
//! `APPIMAGE_ORIGINAL_*` name, restore that. Restoring a saved original is
//! strictly better than unsetting, because the user may genuinely have had
//! an `LD_LIBRARY_PATH` of their own.
//! 2. Otherwise, if the variable differs from the value this process started
//! with, restore the start-up value. That is what undoes *our own*
//! `std::env::set_var` — `main.rs` snapshots the environment via
//! [`capture_pristine_environment`] before any mutation runs.
//! 3. Otherwise, drop only the entries that point inside `$APPDIR`, keeping
//! the rest of the list intact. Blanket-unsetting would also discard
//! whatever the user's session had set; this removes exactly the
//! bundle's own contribution.
//!
//! Nothing is invented: a variable the pristine environment did not have and
//! that does not point into `$APPDIR` is left alone, so outside an AppImage
//! (`cargo tauri dev`, a distro build) this is very close to a no-op.
//!
//! # Portal vs. `xdg-open`
//!
//! `org.freedesktop.portal.OpenURI` would sidestep both the environment leak
//! *and* a missing `x-scheme-handler/https` association, but reaching it means
//! a D-Bus client — `zbus` and its async stack — as a new dependency for one
//! call, on the only platform where we ship a single self-contained binary.
//! It also only helps where a portal is running, which is precisely the
//! desktop-environment case in which `xdg-open` already works once the
//! environment is clean. The environment *is* the bug here, so the cheap fix
//! is the complete one. `gio open` is kept as a second candidate because it
//! goes through GIO's own handler lookup rather than `xdg-open`'s shell
//! heuristics, which covers most of what the portal would have covered.
//!
//! # Security
//!
//! The URL reaching this command originates in an **untrusted container** (see
//! `app/src/lib/urlRelay.ts`). The frontend validates with `sanitizeRelayUrl`,
//! but a compromised webview can call this command directly, so the rules are
//! mirrored here and enforced again: `http`/`https` only, a non-empty host, no
//! embedded credentials, no control characters or whitespace, and a length
//! cap. The URL is never passed through a shell — `std::process::Command` with
//! explicit arguments, so there is no word-splitting, no globbing and no
//! metacharacter to escape.
use std::collections::BTreeMap;
use std::sync::OnceLock;
use url::Url;
/// Hard cap on a URL we will hand to the OS. Mirrors `MAX_RELAY_URL_LENGTH`
/// in `app/src/lib/urlRelay.ts`.
const MAX_URL_LEN: usize = 8192;
/// The environment this process was started with, captured before anything
/// mutates it. See [`capture_pristine_environment`].
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
// tests and the documentation stay in one piece on every platform.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
static PRISTINE_ENV: OnceLock<BTreeMap<String, String>> = OnceLock::new();
/// Record the environment as it was at process start.
///
/// Must be called from `main()` **before** any `std::env::set_var` — today
/// that means before `apply_webkit_wayland_workaround()`, which is the only
/// mutation in the tree. Calling it twice is harmless; the first call wins.
///
/// This is the only reliable source of truth for "what did the user actually
/// have?" for variables *we* set. It cannot recover what `AppRun` overwrote
/// before `main()` ran — that is what the `*_ORIG` and `$APPDIR` rules in
/// [`sanitize_child_env`] are for.
pub fn capture_pristine_environment() {
let _ = PRISTINE_ENV.set(std::env::vars().collect());
}
/// Variables an AppImage launcher is known to override, and that break a
/// cold-launched child that is not this app.
///
/// `PATH` is in the list for the same reason as the rest: `AppRun` prepends
/// `$APPDIR/usr/bin`, and resolving `xdg-open` (or anything the browser's own
/// wrapper script calls) out of the bundle is its own failure mode.
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
// tests and the documentation stay in one piece on every platform.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
const SANITIZED_VARS: &[&str] = &[
"GDK_PIXBUF_MODULEDIR",
"GDK_PIXBUF_MODULE_FILE",
"GIO_MODULE_DIR",
"GSETTINGS_SCHEMA_DIR",
"GTK_DATA_PREFIX",
"GTK_EXE_PREFIX",
"GTK_IM_MODULE_FILE",
"GTK_PATH",
"LD_LIBRARY_PATH",
"PATH",
"PERLLIB",
"PYTHONPATH",
"QT_PLUGIN_PATH",
"XDG_DATA_DIRS",
// Set by `main.rs`, not by AppRun — rule 2 (the pristine snapshot) is what
// removes it, since the pristine environment almost never has it.
"WEBKIT_DISABLE_DMABUF_RENDERER",
];
/// What to do to one variable in the child: `Some(value)` sets it, `None`
/// removes it.
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
// tests and the documentation stay in one piece on every platform.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
type EnvChange = (String, Option<String>);
/// True when `entry` is `appdir` itself or a path inside it.
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
// tests and the documentation stay in one piece on every platform.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn is_inside(entry: &str, appdir: &str) -> bool {
let appdir = appdir.trim_end_matches('/');
if appdir.is_empty() {
return false;
}
entry == appdir || entry.strip_prefix(appdir).is_some_and(|r| r.starts_with('/'))
}
/// Drop the `$APPDIR` entries from a colon-separated list, keeping order and
/// keeping everything else.
///
/// Single-valued variables (`GDK_PIXBUF_MODULE_FILE`, say) are just lists of
/// one, so they need no separate case: a value inside `$APPDIR` filters down
/// to nothing and the variable is removed.
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
// tests and the documentation stay in one piece on every platform.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn strip_appdir_entries(value: &str, appdir: &str) -> Option<String> {
let kept: Vec<&str> = value
.split(':')
.filter(|entry| !entry.is_empty() && !is_inside(entry, appdir))
.collect();
if kept.is_empty() {
None
} else {
Some(kept.join(":"))
}
}
/// Compute the changes that turn `current` into an environment safe to hand a
/// cold-launched host program.
///
/// Pure on purpose — `current` and `pristine` are passed in rather than read
/// from the process, so the rules can be tested without a global mutex around
/// the environment. Returns changes sorted by variable name so assertions are
/// deterministic.
// Only the Linux spawn path reads these; the macOS/Windows path delegates to
// the opener plugin. Kept unconditional (rather than `#[cfg(linux)]`) so the
// tests and the documentation stay in one piece on every platform.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn sanitize_child_env(
current: &BTreeMap<String, String>,
pristine: &BTreeMap<String, String>,
appdir: Option<&str>,
) -> Vec<EnvChange> {
let mut changes: Vec<EnvChange> = Vec::new();
for var in SANITIZED_VARS {
let now = current.get(*var);
// 1. A saved original always wins. Both spellings are checked because
// which one exists depends on the launcher: linuxdeploy's AppRun
// and the various `AppRun.wrapped` generations have used each.
// An empty saved value means "it was unset", not "set it to empty".
let saved = current
.get(&format!("{var}_ORIG"))
.or_else(|| current.get(&format!("APPIMAGE_ORIGINAL_{var}")));
if let Some(saved) = saved {
let restored = if saved.is_empty() {
None
} else {
Some(saved.clone())
};
if restored.as_ref() != now {
changes.push((var.to_string(), restored));
}
continue;
}
// 2. We changed it ourselves after start-up — put back what was there.
let at_start = pristine.get(*var);
if at_start != now {
changes.push((var.to_string(), at_start.cloned()));
continue;
}
// 3. Polluted before `main()` ran, with nothing saved. Remove the
// bundle's own entries and keep the user's.
let (Some(now), Some(appdir)) = (now, appdir) else {
continue;
};
let stripped = strip_appdir_entries(now, appdir);
if stripped.as_deref() != Some(now.as_str()) {
changes.push((var.to_string(), stripped));
}
}
changes.sort_by(|a, b| a.0.cmp(&b.0));
changes
}
/// Whether `candidate` holds a character that disqualifies it before parsing.
///
/// Mirrors `hasForbiddenChar` in `app/src/lib/urlRelay.ts`, and for the same
/// reasons: C0/C1 controls and whitespace are invisible in the UI and are
/// stripped rather than rejected by some URL parsers, and quote characters are
/// illegal in a URL per RFC 3986 while being exactly what an argument-splitting
/// opener downstream would act on. Written as a scan over code points rather
/// than a regex so the control ranges cannot be mangled by an editing tool.
fn has_forbidden_char(candidate: &str) -> bool {
candidate.chars().any(|ch| {
let code = ch as u32;
code <= 0x20
|| code == 0x7f
|| (0x80..=0x9f).contains(&code)
|| ch == '"'
|| ch == '\''
|| ch == '`'
|| ch.is_whitespace()
})
}
/// Validate a URL an untrusted source asked the host to open.
///
/// Returns the normalized URL, or a message safe to show the user. The message
/// never echoes the input: it is the input that is untrusted, and this error
/// is rendered in a toast.
fn validate_external_url(raw: &str) -> Result<String, String> {
// Rust's `trim` strips slightly more than JavaScript's (NEL, U+0085, for
// one), so a string the frontend would have rejected can reach the parser
// here with its edges shaved. That only ever removes outer whitespace —
// everything that survives still has to pass every check below — so the
// divergence cannot widen what gets opened.
let candidate = raw.trim();
if candidate.is_empty() {
return Err("Refused to open an empty URL.".to_string());
}
if candidate.len() > MAX_URL_LEN {
return Err(format!(
"Refused to open a URL longer than {MAX_URL_LEN} characters."
));
}
if has_forbidden_char(candidate) {
return Err(
"Refused to open a URL containing whitespace, quotes or control characters."
.to_string(),
);
}
let parsed = Url::parse(candidate).map_err(|_| "Refused to open a malformed URL.".to_string())?;
// Scheme allowlist. Nothing else, ever — `file:`, `javascript:`, `data:`
// and every registered protocol handler stay out of reach of the
// container. The scheme is safe to interpolate: the parser restricts it to
// ASCII alphanumerics, `+`, `-` and `.`.
if parsed.scheme() != "http" && parsed.scheme() != "https" {
return Err(format!(
"Refused to open a {}: URL — only http and https are allowed.",
parsed.scheme()
));
}
if parsed.host_str().is_none_or(str::is_empty) {
return Err("Refused to open a URL with no host.".to_string());
}
// `https://claude.ai@evil.tld/x` reads as claude.ai anywhere the string is
// truncated, and navigates to evil.tld.
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err("Refused to open a URL containing embedded credentials.".to_string());
}
let normalized = parsed.to_string();
if normalized.len() > MAX_URL_LEN {
return Err(format!(
"Refused to open a URL longer than {MAX_URL_LEN} characters."
));
}
// A normalized http(s) URL is ASCII by construction — the host is
// punycoded and everything after it is percent-encoded. Asserting it means
// nothing non-ASCII can reach an `execvp` argument, whatever the parser
// decides to do in a future version.
if !normalized.is_ascii() {
return Err("Refused to open a URL with non-ASCII characters.".to_string());
}
Ok(normalized)
}
/// Openers to try, in order, each as (program, leading arguments).
///
/// `xdg-open` first because it is what the desktop expects to be asked and
/// honours the user's `mimeapps.list`. `gio open` second: it is present
/// wherever glib is (which, for a GTK app's host, is everywhere) and resolves
/// the handler through GIO rather than `xdg-open`'s shell heuristics, so it
/// still works when the `x-scheme-handler/https` association `xdg-open` looks
/// for is missing or points at something broken.
#[cfg(target_os = "linux")]
const OPENERS: &[(&str, &[&str])] = &[("xdg-open", &[]), ("gio", &["open"])];
/// How long a candidate opener is given to fail before it is assumed to have
/// worked.
///
/// `xdg-open` usually returns immediately (it hands the URL to a running
/// browser and exits), but in its generic fallback mode it *is* the browser's
/// parent and stays alive for the session. So "still running" cannot be read
/// as failure, and "exited non-zero quickly" is the only negative signal there
/// is — though not, on its own, a trustworthy one. See
/// [`exit_code_means_nothing_was_launched`].
#[cfg(target_os = "linux")]
const OPENER_GRACE: std::time::Duration = std::time::Duration::from_millis(400);
/// Whether a non-zero exit says the opener certainly launched nothing, and so
/// that the next candidate can be tried without risking a second tab.
///
/// The loop used to treat every quick non-zero exit as "it did nothing" and
/// fall through. That is safe for most of `xdg-open`'s documented codes — 1
/// (syntax), 2 (file not found) and 3 (a required tool could not be found) are
/// all statements that it never got as far as launching a handler, and 3 is the
/// missing-association case `gio open` is in [`OPENERS`] for. 127 is the same
/// statement made by a shell, which is how a `$BROWSER` or `x-www-browser`
/// wrapper naming a program that does not exist comes back.
///
/// Code 4 is the one that cannot be read that way, and it is the catch-all:
/// "the action failed" also covers a handler that *was* launched and then
/// returned non-zero. A browser that takes the URL, opens the tab in an already
/// running instance and exits non-zero for its own reasons ends up here, as
/// does a wrapper script that does its job and then returns the exit status of
/// something else. Falling through on that hands the same URL to a second
/// opener: two tabs for one click, and for an OAuth link two authorize
/// requests.
///
/// So anything not recognised below — 4, an unfamiliar code, or a death by
/// signal (`code()` is `None`) — ends the loop rather than continuing it. The
/// caller is told the opener failed, which is the honest report of an
/// ambiguous outcome, and no second request is made on the user's behalf. Note
/// what this costs: an opener that genuinely failed with code 4 no longer falls
/// through to `gio`, so a user whose `xdg-open` fails that way sees an error
/// where they previously might have got a tab.
///
/// This is reasoning from `xdg-open`'s documented exit codes, not from an
/// observed double-open in this app.
#[cfg(target_os = "linux")]
fn exit_code_means_nothing_was_launched(code: Option<i32>) -> bool {
matches!(code, Some(1 | 2 | 3 | 127))
}
/// Spawn `url` with an opener, under a sanitized environment.
#[cfg(target_os = "linux")]
fn spawn_with_clean_env(url: &str) -> Result<(), String> {
let current: BTreeMap<String, String> = std::env::vars().collect();
let pristine = PRISTINE_ENV.get().cloned().unwrap_or_else(|| current.clone());
let appdir = current.get("APPDIR").cloned();
let changes = sanitize_child_env(&current, &pristine, appdir.as_deref());
let mut failures: Vec<String> = Vec::new();
for (program, leading) in OPENERS {
let mut command = std::process::Command::new(program);
command.args(*leading).arg(url);
// The bundle's own identity is not the child's business either, and a
// browser that re-execs itself through a wrapper script can pick these
// up.
for var in ["APPDIR", "APPIMAGE", "ARGV0", "OWD"] {
command.env_remove(var);
}
for (key, value) in &changes {
match value {
Some(value) => command.env(key, value),
None => command.env_remove(key),
};
}
// Detached: the opener must not inherit our stdio, or a browser
// writing to stderr keeps a pipe to us open for the session.
command
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
// A spawn failure — `ErrorKind::NotFound` for an opener that is not
// installed, `PermissionDenied` for one that cannot be executed — is
// the unambiguous case: nothing ran, so nothing was opened, and the
// next candidate is free to try.
let mut child = match command.spawn() {
Ok(child) => child,
Err(err) => {
failures.push(format!("{program}: {err}"));
continue;
}
};
std::thread::sleep(OPENER_GRACE);
match child.try_wait() {
Ok(Some(status)) if !status.success() => {
failures.push(format!("{program} exited with {status}"));
// A program that *ran* is not a program that did nothing.
if !exit_code_means_nothing_was_launched(status.code()) {
return Err(format!(
"Could not confirm the link opened. Tried: {}. It may have opened anyway \
— check your browser before trying again.",
failures.join("; ")
));
}
continue;
}
Ok(_) => {}
Err(err) => {
failures.push(format!("{program}: could not be waited on: {err}"));
continue;
}
}
// Still running (it is the browser's parent) — reap it off-thread so it
// does not become a zombie for the life of the app.
std::thread::spawn(move || {
let _ = child.wait();
});
return Ok(());
}
Err(format!(
"Could not open the link. Tried: {}. Check that xdg-utils is installed and that a default browser is set.",
failures.join("; ")
))
}
/// Open `url` in the user's browser.
///
/// On Linux this goes through [`spawn_with_clean_env`] rather than
/// `@tauri-apps/plugin-opener`, for the AppImage reasons in this module's
/// documentation (triple-c#34). macOS and Windows keep the plugin's path —
/// neither has the environment problem, and `open`/`ShellExecute` are the
/// right calls there — but they are reached through this same command so the
/// frontend has one call site with one set of validation rules.
///
/// Errors are returned rather than logged-and-swallowed: "Open" silently doing
/// nothing is the bug being fixed, so the failure has to be something the UI
/// can show.
#[tauri::command]
pub async fn open_url_external(app: tauri::AppHandle, url: String) -> Result<(), String> {
let validated = validate_external_url(&url)?;
#[cfg(target_os = "linux")]
{
let _ = &app;
tauri::async_runtime::spawn_blocking(move || spawn_with_clean_env(&validated))
.await
.map_err(|err| format!("Could not open the link: {err}"))?
}
#[cfg(not(target_os = "linux"))]
{
use tauri_plugin_opener::OpenerExt;
app.opener()
.open_url(validated, None::<&str>)
.map_err(|err| format!("Could not open the link: {err}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
// ── URL re-validation ────────────────────────────────────────────────
#[test]
fn plain_http_and_https_urls_are_accepted() {
for url in [
"https://claude.ai/",
"http://localhost:1420/callback?code=abc",
"https://example.com/path#frag",
] {
assert!(validate_external_url(url).is_ok(), "{url} should be allowed");
}
}
#[test]
fn urls_are_returned_normalized() {
assert_eq!(
validate_external_url("https://Example.COM").unwrap(),
"https://example.com/"
);
}
#[test]
fn only_http_and_https_survive() {
for url in [
"file:///etc/passwd",
"javascript:alert(1)",
"data:text/html,<script>",
"ftp://example.com/x",
"vscode://foo/bar",
"mailto:someone@example.com",
] {
assert!(
validate_external_url(url).is_err(),
"{url} must not be openable"
);
}
}
#[test]
fn embedded_credentials_are_refused() {
for url in [
"https://claude.ai@evil.tld/x",
"https://user:pass@example.com/",
"https://:pass@example.com/",
] {
assert!(
validate_external_url(url).is_err(),
"{url} must not be openable"
);
}
}
#[test]
fn control_characters_and_whitespace_are_refused() {
// `\n` in particular: parsers that strip it would turn the first of
// these into a `javascript:` URL.
for url in [
"java\nscript:alert(1)",
"https://example.com/\u{7f}",
"https://example.com/\u{85}x",
"https://example.com/a b",
"https://example.com/\u{00a0}x",
"https://example.com/\"",
"https://example.com/'",
"https://example.com/`",
] {
assert!(
validate_external_url(url).is_err(),
"{url:?} must not be openable"
);
}
}
#[test]
fn empty_and_oversized_are_refused() {
assert!(validate_external_url("").is_err());
assert!(validate_external_url(" ").is_err());
let long = format!("https://example.com/{}", "a".repeat(MAX_URL_LEN));
assert!(validate_external_url(&long).is_err());
}
#[test]
fn a_host_is_required() {
assert!(validate_external_url("https://").is_err());
assert!(validate_external_url("http://:8080/").is_err());
// Not a missing host: WHATWG's "special authority ignore slashes"
// state eats the third slash, so this is the host `path` in both
// `new URL()` and here. Asserted so the parity is on the record.
assert_eq!(
validate_external_url("http:///path").unwrap(),
"http://path/"
);
}
#[test]
fn error_messages_never_echo_the_input() {
// The input is attacker-controlled and the message goes into a toast.
let err = validate_external_url("file:///home/someone/.ssh/id_rsa").unwrap_err();
assert!(!err.contains("id_rsa"), "message leaked the input: {err}");
}
// ── Environment sanitization ─────────────────────────────────────────
#[test]
fn appdir_entries_are_stripped_and_the_users_own_are_kept() {
let current = map(&[
("APPDIR", "/tmp/.mount_abc"),
("LD_LIBRARY_PATH", "/tmp/.mount_abc/usr/lib:/opt/mine/lib"),
("XDG_DATA_DIRS", "/tmp/.mount_abc/usr/share:/usr/share"),
]);
let changes = sanitize_child_env(&current, &current, Some("/tmp/.mount_abc"));
assert_eq!(
changes,
vec![
(
"LD_LIBRARY_PATH".to_string(),
Some("/opt/mine/lib".to_string())
),
("XDG_DATA_DIRS".to_string(), Some("/usr/share".to_string())),
]
);
}
#[test]
fn a_variable_that_is_entirely_appdir_is_removed() {
let current = map(&[
("APPDIR", "/tmp/.mount_abc"),
("GTK_PATH", "/tmp/.mount_abc/usr/lib/gtk-3.0"),
(
"GDK_PIXBUF_MODULE_FILE",
"/tmp/.mount_abc/usr/lib/gdk-pixbuf/loaders.cache",
),
]);
let changes = sanitize_child_env(&current, &current, Some("/tmp/.mount_abc"));
assert_eq!(
changes,
vec![
("GDK_PIXBUF_MODULE_FILE".to_string(), None),
("GTK_PATH".to_string(), None),
]
);
}
#[test]
fn a_saved_original_is_restored_rather_than_unset() {
// Restoring beats unsetting: the user may have had one of their own.
for saved_as in ["LD_LIBRARY_PATH_ORIG", "APPIMAGE_ORIGINAL_LD_LIBRARY_PATH"] {
let current = map(&[
("APPDIR", "/tmp/.mount_abc"),
("LD_LIBRARY_PATH", "/tmp/.mount_abc/usr/lib"),
(saved_as, "/home/someone/lib"),
]);
let changes = sanitize_child_env(&current, &current, Some("/tmp/.mount_abc"));
assert_eq!(
changes,
vec![(
"LD_LIBRARY_PATH".to_string(),
Some("/home/someone/lib".to_string())
)],
"{saved_as} should be restored"
);
}
}
#[test]
fn an_empty_saved_original_means_it_was_unset() {
let current = map(&[
("APPDIR", "/tmp/.mount_abc"),
("LD_LIBRARY_PATH", "/tmp/.mount_abc/usr/lib"),
("LD_LIBRARY_PATH_ORIG", ""),
]);
let changes = sanitize_child_env(&current, &current, Some("/tmp/.mount_abc"));
assert_eq!(changes, vec![("LD_LIBRARY_PATH".to_string(), None)]);
}
#[test]
fn our_own_set_var_is_undone_from_the_pristine_snapshot() {
// The leak `main.rs` documents: we set this after start-up, so the
// start-up snapshot is what says it should not exist at all.
let pristine = map(&[("HOME", "/home/someone")]);
let current = map(&[
("HOME", "/home/someone"),
("WEBKIT_DISABLE_DMABUF_RENDERER", "1"),
]);
let changes = sanitize_child_env(&current, &pristine, None);
assert_eq!(
changes,
vec![("WEBKIT_DISABLE_DMABUF_RENDERER".to_string(), None)]
);
}
#[test]
fn a_value_the_user_set_themselves_is_left_alone() {
let pristine = map(&[("WEBKIT_DISABLE_DMABUF_RENDERER", "1")]);
let current = pristine.clone();
assert!(sanitize_child_env(&current, &pristine, None).is_empty());
}
#[test]
fn outside_an_appimage_nothing_is_touched() {
let env = map(&[
("PATH", "/usr/bin:/bin"),
("LD_LIBRARY_PATH", "/opt/mine/lib"),
("XDG_DATA_DIRS", "/usr/share"),
]);
assert!(
sanitize_child_env(&env, &env, None).is_empty(),
"a dev build or distro build must not have its environment rewritten"
);
}
#[test]
fn nothing_is_invented_for_variables_that_were_never_set() {
let env = map(&[("APPDIR", "/tmp/.mount_abc")]);
assert!(sanitize_child_env(&env, &env, Some("/tmp/.mount_abc")).is_empty());
}
#[test]
fn a_prefix_that_merely_looks_like_appdir_is_not_stripped() {
// `/tmp/.mount_abc-other` is not inside `/tmp/.mount_abc`.
let env = map(&[
("APPDIR", "/tmp/.mount_abc"),
("LD_LIBRARY_PATH", "/tmp/.mount_abc-other/lib"),
]);
assert!(sanitize_child_env(&env, &env, Some("/tmp/.mount_abc")).is_empty());
}
#[test]
fn a_trailing_slash_on_appdir_still_matches() {
let env = map(&[
("APPDIR", "/tmp/.mount_abc/"),
("GTK_PATH", "/tmp/.mount_abc/usr/lib/gtk-3.0"),
]);
let changes = sanitize_child_env(&env, &env, Some("/tmp/.mount_abc/"));
assert_eq!(changes, vec![("GTK_PATH".to_string(), None)]);
}
}
#[cfg(all(test, target_os = "linux"))]
mod opener_fallback_tests {
use super::*;
/// The codes `xdg-open` documents as "nothing was launched". Falling
/// through to the next opener on these is what keeps `gio open` reachable
/// for the case it was added for: no usable `x-scheme-handler/https`
/// association.
#[test]
fn the_codes_that_mean_no_handler_ran_fall_through() {
for code in [1, 2, 3, 127] {
assert!(
exit_code_means_nothing_was_launched(Some(code)),
"exit {code} means the opener never launched anything"
);
}
}
/// The regression this guards: `xdg-open` returns 4 both when it could not
/// act and when the handler it launched returned non-zero — including a
/// browser that had already opened the tab. Trying `gio open` next would
/// open it a second time, which for an OAuth URL is a second authorize
/// request.
#[test]
fn an_exit_that_may_follow_a_successful_open_does_not_fall_through() {
assert!(!exit_code_means_nothing_was_launched(Some(4)));
for code in [5, 7, 126, 255] {
assert!(
!exit_code_means_nothing_was_launched(Some(code)),
"exit {code} is not a documented 'did nothing', so it must not be assumed to be one"
);
}
}
/// Killed by a signal: `code()` is `None` and the outcome is unknowable,
/// so it is treated like any other unrecognised exit.
#[test]
fn a_death_by_signal_does_not_fall_through() {
assert!(!exit_code_means_nothing_was_launched(None));
}
}