mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
Found by the three-part review of this branch. Each was checked against the source first; every decision that can run without a window has a unit test. This Mac: - A Codeman that runs but listens nowhere yet (the first seconds after login, a restart) was reported as "already running ... it listens at process N, which this app cannot open", and Start was refused for it. It is now its own state: the tab says "starting", looks again every second, and opens Codeman once it is up when a connect to this Mac had just been refused. The state table is a pure function with a test (setup::local_state). - A server the app starts runs in the home folder, like one started by launchd or in a terminal. It ran in the app's own directory (`/` after a Finder launch), which Codeman then used as the default for sessions. - Server detection accepts any entry word that `web` follows, so a dev server under a folder named codeman is seen and never gets a second server beside it. - A login shell is polled from 5 ms up instead of every 100 ms, and the port after a launchd start every 100 ms instead of 500: both were pure waiting. - The last line of stdout is no longer glued to the first line of stderr. Addresses and policy: - A tailnet name typed with a port gets http://, not https://. With a port it is Codeman's own listener; https there ended in a TLS error with no hint. - within_base refuses `..%2F` and the other encoded ways out of the base path, so a sibling app behind a decoding proxy never sees the login. - A status answer whose body stalls is a timeout, not "something else answers here". - The folder pop-out pattern matches the two routes the server has. - A malformed `window` in desktop.json no longer costs the server list. Windows: - Pop-outs step off the window in use. tao centres a window with no position, so every pop-out opened exactly on top of the one before. - A window opened by a script no longer brings the app to the front: wry activated the application for it, whatever the window itself was told. - The first-run window sits between the menu bar and the Dock. Centred in the whole display, its bottom was under the Dock on a 900 pt high one. - The window frame is saved once after a move or resize ends, not once a second during the drag. Also makes the connect page independent of TextEncoder and scrollIntoView, so it runs in jsdom as well as in a web view. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1692 lines
70 KiB
Rust
1692 lines
70 KiB
Rust
//! Every decision the window makes about URLs and messages, as pure functions.
|
|
//!
|
|
//! Nothing here touches the UI, the network or the disk, so the whole module is
|
|
//! unit-tested on its own. That matters more than usual here: the frames inside
|
|
//! the window can show content Codeman did not write (a rendered HTML file, a
|
|
//! proxied dashboard), and these functions are what keeps that content from
|
|
//! steering the app.
|
|
|
|
use std::net::Ipv4Addr;
|
|
use std::time::Instant;
|
|
|
|
use base64::Engine as _;
|
|
use serde::Deserialize;
|
|
use url::{Host, Position, Url};
|
|
|
|
/// The custom scheme the bundled connect page is served from.
|
|
pub const APP_SCHEME: &str = "codemanapp";
|
|
/// The connect page itself (wry serves custom schemes as `<scheme>://localhost/`).
|
|
pub const CONNECT_PAGE: &str = "codemanapp://localhost/index.html";
|
|
|
|
/// Caps on what a page may hand the native side.
|
|
pub const MAX_URL_LEN: usize = 4096;
|
|
pub const MAX_FIELD_LEN: usize = 1024;
|
|
|
|
/// Where the main window is in reaching a server. The gates below take it
|
|
/// instead of "is a server set", because what may talk to the native side
|
|
/// changes when a server page COMMITS, not when the user picks a server: until
|
|
/// the load commits, the connect page is still the live document, and it has
|
|
/// to stay usable if the load never arrives.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Phase {
|
|
/// The connect page shows and no server is chosen.
|
|
Connect,
|
|
/// The server's URL was handed to the main web view but has not committed
|
|
/// yet; the connect page is still the live document.
|
|
Loading(Url),
|
|
/// A server page committed in the main window.
|
|
Loaded(Url),
|
|
}
|
|
|
|
impl Phase {
|
|
/// The chosen server, loading or loaded.
|
|
pub fn server(&self) -> Option<&Url> {
|
|
match self {
|
|
Phase::Connect => None,
|
|
Phase::Loading(url) | Phase::Loaded(url) => Some(url),
|
|
}
|
|
}
|
|
|
|
/// The server whose page is the main window's document.
|
|
pub fn loaded(&self) -> Option<&Url> {
|
|
match self {
|
|
Phase::Loaded(url) => Some(url),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Whether the connect page is still the main window's document.
|
|
pub fn connect_page_live(&self) -> bool {
|
|
matches!(self, Phase::Connect | Phase::Loading(_))
|
|
}
|
|
}
|
|
|
|
/// Turn what the user typed into a server URL: scheme added when missing,
|
|
/// query and fragment dropped, and the path ending in `/` so `api/status`
|
|
/// joins below a base path. The scheme is `https` for a tailnet `.ts.net`
|
|
/// name on its own, which is `tailscale serve`, and `http` otherwise. A
|
|
/// `.ts.net` name with a port is Codeman's own listener, which speaks plain
|
|
/// http: guessing https there ended in a TLS error with no way forward, while
|
|
/// the other mistake (http to an https port) gets a button for the https
|
|
/// address (ProbeOutcome::MaybeHttps).
|
|
pub fn normalize_server_url(input: &str) -> Result<Url, String> {
|
|
let s = input.trim();
|
|
if s.is_empty() {
|
|
return Err("Enter the address of your Codeman server.".into());
|
|
}
|
|
if s.len() > MAX_URL_LEN {
|
|
return Err("That address is too long.".into());
|
|
}
|
|
let with_scheme = if s.contains("://") {
|
|
s.to_string()
|
|
} else {
|
|
let (host, rest) = s.split_at(s.find(['/', ':']).unwrap_or(s.len()));
|
|
let scheme = if host.to_ascii_lowercase().ends_with(".ts.net") && !rest.starts_with(':') {
|
|
"https"
|
|
} else {
|
|
"http"
|
|
};
|
|
format!("{scheme}://{s}")
|
|
};
|
|
let mut url = Url::parse(&with_scheme).map_err(|e| format!("That address is not valid ({e})."))?;
|
|
match url.scheme() {
|
|
"http" | "https" => {}
|
|
other => return Err(format!("Use an http:// or https:// address, not {other}://.")),
|
|
}
|
|
if url.host_str().is_none_or(str::is_empty) {
|
|
return Err("That address has no host name.".into());
|
|
}
|
|
if !url.username().is_empty() || url.password().is_some() {
|
|
return Err("Put the username and password in their own fields, not in the address.".into());
|
|
}
|
|
url.set_query(None);
|
|
url.set_fragment(None);
|
|
if !url.path().ends_with('/') {
|
|
let path = format!("{}/", url.path());
|
|
url.set_path(&path);
|
|
}
|
|
Ok(url)
|
|
}
|
|
|
|
/// Same scheme, host and port (the browser's notion of an origin).
|
|
pub fn same_origin(a: &Url, b: &Url) -> bool {
|
|
a.scheme() == b.scheme() && a.host_str() == b.host_str() && a.port_or_known_default() == b.port_or_known_default()
|
|
}
|
|
|
|
/// Whether `url` lies below the server's base path on the server's origin. The
|
|
/// Basic `Authorization` header goes only to such URLs, so another app sharing
|
|
/// the origin (`/grafana/` next to `/codeman/`) never receives the password.
|
|
/// `server.path()` always ends in `/` (normalize_server_url), so `/codemanx/`
|
|
/// is not below `/codeman/`. The part below the base is decoded once, as
|
|
/// is_popout_target does, and may hold no `.` or `..` segment: the url crate
|
|
/// resolves a literal `..`, but `..%2Fgrafana/` reaches a proxy that decodes
|
|
/// before it routes as a way out of the base. A path that does not decode is
|
|
/// not below it either (fail closed).
|
|
pub fn within_base(url: &Url, server: &Url) -> bool {
|
|
same_origin(url, server)
|
|
&& below_base_decoded(url, server)
|
|
.is_some_and(|rest| !rest.split(['/', '\\']).any(|segment| segment == "." || segment == ".."))
|
|
}
|
|
|
|
/// A server on this machine: `localhost`, `127.0.0.0/8` or `::1`.
|
|
pub fn is_loopback(url: &Url) -> bool {
|
|
match url.host() {
|
|
Some(Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"),
|
|
Some(Host::Ipv4(ip)) => ip.is_loopback(),
|
|
Some(Host::Ipv6(ip)) => ip.is_loopback(),
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
/// Whether the app may offer to start Codeman for this address: plain HTTP to
|
|
/// `localhost` or `127.0.0.1`. `codeman web -d` binds 127.0.0.1 (start_args
|
|
/// passes no `--host`, because Codeman's hooks call the server at 127.0.0.1),
|
|
/// so for another loopback address (`::1`, `127.0.0.2`) the start would
|
|
/// succeed and the window still could not reach it. An `https://` loopback
|
|
/// server would carry Codeman's self-signed certificate, which the web view
|
|
/// refuses, so starting one would only lead to a second failure.
|
|
pub fn can_start_locally(url: &Url) -> bool {
|
|
url.scheme() == "http"
|
|
&& is_loopback(url)
|
|
&& match url.host() {
|
|
Some(Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"),
|
|
Some(Host::Ipv4(ip)) => ip == Ipv4Addr::LOCALHOST,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// The `codeman web` arguments that start a server answering at `url`.
|
|
pub fn start_args(url: &Url) -> Vec<String> {
|
|
let port = url.port_or_known_default().unwrap_or(80);
|
|
let mut args = vec![
|
|
"web".to_string(),
|
|
"-d".to_string(),
|
|
"--port".to_string(),
|
|
port.to_string(),
|
|
];
|
|
let path = url.path().trim_end_matches('/');
|
|
if !path.is_empty() {
|
|
args.push("--base-url".into());
|
|
args.push(path.to_string());
|
|
}
|
|
args
|
|
}
|
|
|
|
/// Whether a frame runs Codeman's own app shell, the only page allowed to use
|
|
/// `window.CodemanHost`: the dashboard (the base path itself or `index.html`)
|
|
/// and a session pop-out (`session/<id>`). An allowlist, so rendered HTML
|
|
/// files (`html-view/`), proxied dashboards (`webview/`) and any route Codeman
|
|
/// adds later stay out. The raw path must hold no `%` at all: the server routes
|
|
/// the DECODED path while this compares the raw one, so `/%68tml-view/`
|
|
/// reaches the html-view route while reading as some other path here.
|
|
pub fn trusted_frame(frame: &Url, server: &Url) -> bool {
|
|
if !same_origin(frame, server) || frame.path().contains('%') {
|
|
return false;
|
|
}
|
|
match frame.path().strip_prefix(server.path()) {
|
|
Some("" | "index.html") => true,
|
|
Some(rest) => rest
|
|
.strip_prefix("session/")
|
|
.is_some_and(|id| !id.is_empty() && !id.contains('/')),
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
/// Whether a same-origin URL is one of the pages Codeman's frontend opens in a
|
|
/// window of its own, and so may become an app window: a session pop-out
|
|
/// (`session/<id>`), a rendered HTML file (`html-view/...`, served with a
|
|
/// sandbox CSP), a detached file preview or attachment (`api/sessions/<id>/`
|
|
/// `file-raw`, `file-preview`, `attachments/<id>/raw|preview`,
|
|
/// `folders/<id>/files/...`, and the path picker's `api/filesystem/preview`)
|
|
/// and a download (`api/download`). A script's `window.open` gets only the
|
|
/// last two kinds (new_window, Via::Script).
|
|
///
|
|
/// Everything else on the origin is refused, a proxied dashboard under
|
|
/// `webview/` above all. A browser keeps the opener frame's sandbox on a popup;
|
|
/// a fresh web view does not, so a dashboard opened here would run top-level as
|
|
/// Codeman's origin, cookie and all. The server's router matches the decoded
|
|
/// path, so the part below the base is decoded once, and anything a second
|
|
/// decoding or a path normalizer could still turn into another route (a `%`
|
|
/// left over, `.` or `..` segments, a backslash, NUL, userinfo) fails closed.
|
|
pub fn is_popout_target(url: &Url, server: &Url) -> bool {
|
|
popout_kind(url, server).is_some()
|
|
}
|
|
|
|
/// The session id of a session pop-out (`session/<id>`), or None for any other
|
|
/// URL, a pop-out target of another kind included. Read through
|
|
/// is_popout_target's own classification, so it is Some exactly when that
|
|
/// accepts the URL as a session page. The id is decoded once, the form the
|
|
/// server's router hands the session route: `session/a%62c` and
|
|
/// `%73ession/abc` both give `abc`. Codeman opens pop-outs as
|
|
/// `session/<encodeURIComponent(id)>`, so this is the id its dashboard knows.
|
|
pub fn session_id_of(url: &Url, server: &Url) -> Option<String> {
|
|
match popout_kind(url, server)? {
|
|
Popout::Session(id) => Some(id),
|
|
Popout::HtmlView | Popout::File => None,
|
|
}
|
|
}
|
|
|
|
/// The kinds of page is_popout_target accepts. new_window tells them apart
|
|
/// by who asked (Via), session_id_of reads the session id.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum Popout {
|
|
/// `session/<id>`, the id decoded once.
|
|
Session(String),
|
|
/// `html-view/...`: a rendered HTML file.
|
|
HtmlView,
|
|
/// A detached file preview, an attachment, a file in a granted folder,
|
|
/// the path picker's preview, or a download (`api/download`).
|
|
File,
|
|
}
|
|
|
|
/// is_popout_target, saying which kind of page it found.
|
|
fn popout_kind(url: &Url, server: &Url) -> Option<Popout> {
|
|
if !same_origin(url, server) || !url.username().is_empty() || url.password().is_some() {
|
|
return None;
|
|
}
|
|
let rest = below_base_decoded(url, server)?;
|
|
if rest.contains(['%', '\\', '\0']) {
|
|
return None;
|
|
}
|
|
let segments: Vec<&str> = rest.split('/').collect();
|
|
if segments.iter().any(|s| *s == "." || *s == "..") {
|
|
return None;
|
|
}
|
|
if let Some(tail) = rest.strip_prefix("html-view/") {
|
|
return (!tail.is_empty()).then_some(Popout::HtmlView);
|
|
}
|
|
match segments.as_slice() {
|
|
["session", id] if !id.is_empty() => Some(Popout::Session((*id).to_string())),
|
|
["api", "download"] | ["api", "filesystem", "preview"] => Some(Popout::File),
|
|
["api", "sessions", id, "file-raw" | "file-preview"] if !id.is_empty() => Some(Popout::File),
|
|
["api", "sessions", id, "attachments", attachment, "raw" | "preview"]
|
|
if !id.is_empty() && !attachment.is_empty() =>
|
|
{
|
|
Some(Popout::File)
|
|
}
|
|
["api", "sessions", id, "folders", folder, "files", name, "raw" | "preview"]
|
|
if !id.is_empty() && !folder.is_empty() && !name.is_empty() =>
|
|
{
|
|
Some(Popout::File)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The part of `url`'s path below the server's base path, percent-decoded once
|
|
/// (the way the server's router sees it). None when the raw path is not below
|
|
/// the base, or does not decode. The base is matched on the RAW path, as the
|
|
/// server's own base-path strip does, so `/%63odeman/` is not below `/codeman/`.
|
|
fn below_base_decoded(url: &Url, server: &Url) -> Option<String> {
|
|
percent_decode_once(url.path().strip_prefix(server.path())?)
|
|
}
|
|
|
|
/// Whether a Basic challenge whose 401 came from `failed` (the failure
|
|
/// response's URL) is Codeman's own login, the only one the app answers with
|
|
/// the saved password and the only one whose refusal sends the user back to
|
|
/// the connect screen. It has to lie below the base path on the server's
|
|
/// origin (within_base), which keeps another app on the same origin from ever
|
|
/// seeing the password, and it must not be on one of the two routes where a
|
|
/// 401 says nothing about the saved login:
|
|
///
|
|
/// - `webview/` relays a proxied dashboard's status and headers,
|
|
/// `WWW-Authenticate` included, so a Basic-protected dashboard would
|
|
/// otherwise be answered with Codeman's login and, when it refuses that,
|
|
/// read as Codeman refusing it.
|
|
/// - `html-view/` serves an agent-written page that runs in an opaque origin
|
|
/// with its capability as its only credential, so a 401 there is a dead
|
|
/// capability, never a reason to send the password.
|
|
///
|
|
/// The part below the base is decoded once with the decoder is_popout_target
|
|
/// uses, as the server's router sees it, so `%77ebview/` counts as `webview/`;
|
|
/// a path that does not decode is not ours (fail closed: WebKit's default
|
|
/// handling, never the password). What this cannot see: a dashboard asset
|
|
/// requested by a ROOT-ABSOLUTE URL (`fetch('/api/data')`), which Codeman
|
|
/// relays to the dashboard by its `Referer`, carries no `webview/` in its path,
|
|
/// so its 401 reads as in scope here.
|
|
pub fn challenge_in_scope(failed: &Url, server: &Url) -> bool {
|
|
if !within_base(failed, server) {
|
|
return false;
|
|
}
|
|
below_base_decoded(failed, server)
|
|
.is_some_and(|rest| !rest.starts_with("webview/") && !rest.starts_with("html-view/"))
|
|
}
|
|
|
|
/// Decode `%XX` escapes once. None for a `%` that starts no escape, or for
|
|
/// bytes that are not UTF-8 (fail closed: no Codeman route needs either).
|
|
fn percent_decode_once(s: &str) -> Option<String> {
|
|
let bytes = s.as_bytes();
|
|
let mut out = Vec::with_capacity(bytes.len());
|
|
let mut i = 0;
|
|
while i < bytes.len() {
|
|
if bytes[i] == b'%' {
|
|
let hi = hex_value(*bytes.get(i + 1)?)?;
|
|
let lo = hex_value(*bytes.get(i + 2)?)?;
|
|
out.push((hi << 4) | lo);
|
|
i += 3;
|
|
} else {
|
|
out.push(bytes[i]);
|
|
i += 1;
|
|
}
|
|
}
|
|
String::from_utf8(out).ok()
|
|
}
|
|
|
|
fn hex_value(b: u8) -> Option<u8> {
|
|
(b as char).to_digit(16).map(|d| d as u8)
|
|
}
|
|
|
|
/// `url` as a string NSURL accepts on every macOS the app supports. Before
|
|
/// macOS 14, `URLWithString:` parses by the old RFCs and returns nil for
|
|
/// characters the WHATWG serializer leaves raw (`| ^ [ ]` in a path, `{ }` and
|
|
/// more in a query), and wry unwraps that nil, which aborts the app. Those are
|
|
/// percent-encoded in the path, query and fragment only, never in the scheme,
|
|
/// host or port (an IPv6 literal keeps its brackets). Existing `%XX` escapes
|
|
/// stay as they are, and a `%` that starts none becomes `%25`. The first `#`
|
|
/// starts the fragment; a second one becomes `%23`.
|
|
pub fn webkit_safe_url(url: &Url) -> String {
|
|
let head = &url[..Position::BeforePath];
|
|
let tail = &url[Position::BeforePath..];
|
|
let bytes = tail.as_bytes();
|
|
let mut out = String::with_capacity(url.as_str().len() + 16);
|
|
out.push_str(head);
|
|
let mut in_fragment = false;
|
|
for (i, c) in tail.char_indices() {
|
|
let encode = match c {
|
|
'#' if !in_fragment => {
|
|
in_fragment = true;
|
|
false
|
|
}
|
|
'%' => !(bytes.len() > i + 2 && bytes[i + 1].is_ascii_hexdigit() && bytes[i + 2].is_ascii_hexdigit()),
|
|
' ' | '"' | '<' | '>' | '\\' | '^' | '`' | '{' | '|' | '}' | '[' | ']' | '#' => true,
|
|
_ => false,
|
|
};
|
|
if encode {
|
|
// Every character in the set above is ASCII.
|
|
let b = c as u8;
|
|
const HEX: &[u8; 16] = b"0123456789ABCDEF";
|
|
out.push('%');
|
|
out.push(HEX[usize::from(b >> 4)] as char);
|
|
out.push(HEX[usize::from(b & 0x0f)] as char);
|
|
} else {
|
|
out.push(c);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// How often the app hands a page to the default browser without the user
|
|
/// asking each time: a burst of 3, then one a second. macOS lets a page call
|
|
/// `window.open` without a click, so without a budget one script could fill
|
|
/// the browser with tabs. Openings over budget are dropped, never queued.
|
|
/// main.rs keeps a separate one of these for the app windows a script opens
|
|
/// (new_window with Via::Script), so a frame looping `window.open` on a file
|
|
/// route cannot stack windows either, and cannot use up this budget, which the
|
|
/// user's own link clicks draw on.
|
|
#[derive(Debug, Clone)]
|
|
pub struct OpenBudget {
|
|
tokens: f64,
|
|
last: Option<Instant>,
|
|
}
|
|
|
|
const OPEN_BURST: f64 = 3.0;
|
|
const OPEN_REFILL_PER_SEC: f64 = 1.0;
|
|
|
|
impl OpenBudget {
|
|
pub fn new() -> Self {
|
|
OpenBudget {
|
|
tokens: OPEN_BURST,
|
|
last: None,
|
|
}
|
|
}
|
|
|
|
/// Spend one opening at `now` if the budget has one. The caller passes the
|
|
/// clock, so this stays pure; a `now` earlier than the last call refills
|
|
/// nothing.
|
|
pub fn allow(&mut self, now: Instant) -> bool {
|
|
if let Some(last) = self.last {
|
|
let elapsed = now.saturating_duration_since(last).as_secs_f64();
|
|
self.tokens = (self.tokens + elapsed * OPEN_REFILL_PER_SEC).min(OPEN_BURST);
|
|
}
|
|
if self.last.is_none_or(|last| now > last) {
|
|
self.last = Some(now);
|
|
}
|
|
if self.tokens >= 1.0 {
|
|
self.tokens -= 1.0;
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for OpenBudget {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// What a window does once its web content process has ended: a crash, or the
|
|
/// system taking the process back under memory pressure. The window is blank
|
|
/// from then on, since nothing reloads it by itself.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Recovery {
|
|
/// Load the window's page again.
|
|
Reload,
|
|
/// Show the connect page: with the reason, when a server page was given up.
|
|
ConnectPage { gave_up: bool },
|
|
/// Close the pop-out.
|
|
Close,
|
|
/// Leave the window as it is.
|
|
Nothing,
|
|
}
|
|
|
|
/// How a window recovers from the end of its web content process. `on_server`:
|
|
/// a server page is the main window's document (pop-outs only exist then).
|
|
/// `again`: the same window lost its process a moment ago already, so its page
|
|
/// cannot stay up and loading it once more would only repeat that.
|
|
pub fn recovery(is_main: bool, on_server: bool, again: bool) -> Recovery {
|
|
match (is_main, on_server, again) {
|
|
(_, true, false) => Recovery::Reload,
|
|
(true, true, true) => Recovery::ConnectPage { gave_up: true },
|
|
// The connect page itself, or a server load that had not committed.
|
|
(true, false, false) => Recovery::ConnectPage { gave_up: false },
|
|
(true, false, true) => Recovery::Nothing,
|
|
(false, _, true) => Recovery::Close,
|
|
// A pop-out with no server page behind it is on its way out anyway.
|
|
(false, false, false) => Recovery::Nothing,
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum Navigation {
|
|
Allow,
|
|
Block,
|
|
}
|
|
|
|
/// Whether a navigation may proceed. The handler sees every frame's
|
|
/// navigations (wry does not say which frame), so this has to be safe for an
|
|
/// iframe too: anything off the server's origin is blocked, never opened in
|
|
/// the browser, or a page full of external iframes would spray browser tabs.
|
|
/// Real link clicks to other sites are routed out by the main-frame click
|
|
/// handler in the host shim instead.
|
|
///
|
|
/// The server's origin is allowed from the moment its load starts (the load
|
|
/// itself is such a navigation). The connect page is allowed until a server
|
|
/// page commits, so the app can abandon a load that never arrives and show the
|
|
/// connect page again; once a server page is the document, nothing on it can
|
|
/// pull the connect page into a frame.
|
|
pub fn navigation(target: &str, phase: &Phase) -> Navigation {
|
|
let Ok(url) = Url::parse(target) else {
|
|
return Navigation::Block;
|
|
};
|
|
match url.scheme() {
|
|
"about" | "blob" | "data" => Navigation::Allow,
|
|
s if s == APP_SCHEME => {
|
|
if phase.connect_page_live() {
|
|
Navigation::Allow
|
|
} else {
|
|
Navigation::Block
|
|
}
|
|
}
|
|
"http" | "https" => match phase.server() {
|
|
Some(srv) if same_origin(&url, srv) => Navigation::Allow,
|
|
_ => Navigation::Block,
|
|
},
|
|
_ => Navigation::Block,
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum NewWindow {
|
|
/// One of Codeman's own pop-out pages that this path may open (see
|
|
/// new_window): a full app window with every handler attached.
|
|
AppWindow(Url),
|
|
/// Another site: the default browser.
|
|
External(Url),
|
|
/// `window.open` returns null. Covers `window.open('')`/`about:blank`: the
|
|
/// window WebKit would build for those carries none of the app's handlers,
|
|
/// so a sandboxed frame could point it anywhere.
|
|
Deny,
|
|
}
|
|
|
|
/// Who asked for a new window.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Via {
|
|
/// `window.open` or a `target=_blank` link, which WebKit hands the
|
|
/// new-window handler from ANY frame without saying which: a rendered HTML
|
|
/// file or a proxied dashboard can ask this way as easily as the dashboard.
|
|
Script,
|
|
/// `CodemanHost.openWindow`, which parse_ipc accepts only from a frame
|
|
/// running Codeman's own app shell (trusted_frame).
|
|
Host,
|
|
}
|
|
|
|
/// What to do with a request for a new window. Only a committed server page
|
|
/// opens anything (the connect page uses IPC for its links). Another origin
|
|
/// goes to the default browser. On the server's origin only pop-out targets
|
|
/// become app windows, and which ones depends on who asked:
|
|
///
|
|
/// - Via::Host: every pop-out target (is_popout_target).
|
|
/// - Via::Script: only the file and download routes, the pages Codeman itself
|
|
/// opens with `window.open`. Codeman opens session pop-outs and rendered
|
|
/// HTML files through `CodemanHost.openWindow` when it runs in the app, so a
|
|
/// script asking for `session/<id>` or `html-view/` is some other frame, and
|
|
/// gets nothing: a rendered HTML file could otherwise open copies of itself
|
|
/// as top-level windows, each passing for one of the app's own.
|
|
///
|
|
/// Every other same-origin URL is denied, never sent to the browser, where a
|
|
/// top-level `webview/<cap>/` load would escape the iframe sandbox just the
|
|
/// same.
|
|
pub fn new_window(target: &str, phase: &Phase, via: Via) -> NewWindow {
|
|
if target.len() > MAX_URL_LEN {
|
|
return NewWindow::Deny;
|
|
}
|
|
let Ok(url) = Url::parse(target) else {
|
|
return NewWindow::Deny;
|
|
};
|
|
if !matches!(url.scheme(), "http" | "https") {
|
|
return NewWindow::Deny;
|
|
}
|
|
let Some(srv) = phase.loaded() else {
|
|
return NewWindow::Deny;
|
|
};
|
|
if !same_origin(&url, srv) {
|
|
return NewWindow::External(url);
|
|
}
|
|
match (popout_kind(&url, srv), via) {
|
|
(Some(Popout::File), _) | (Some(_), Via::Host) => NewWindow::AppWindow(url),
|
|
_ => NewWindow::Deny,
|
|
}
|
|
}
|
|
|
|
/// `Authorization` header value for HTTP Basic auth.
|
|
pub fn basic_auth_header(username: &str, password: &str) -> String {
|
|
let raw = format!("{username}:{password}");
|
|
format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(raw))
|
|
}
|
|
|
|
/// Calls the server's own page makes through `window.CodemanHost`.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum HostCmd {
|
|
OpenWindow(String),
|
|
CloseWindow,
|
|
FocusWindow,
|
|
/// A main-frame click on a link to another site.
|
|
OpenExternal(String),
|
|
/// The page got a 401 from its own server: the session cookie is gone (a
|
|
/// server restart) and the page cannot recover on its own.
|
|
SessionExpired,
|
|
}
|
|
|
|
/// Calls the bundled connect page makes.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum ConnectCmd {
|
|
Ready,
|
|
Connect {
|
|
url: String,
|
|
username: Option<String>,
|
|
password: Option<String>,
|
|
remember: bool,
|
|
},
|
|
Cancel,
|
|
StartServer {
|
|
url: String,
|
|
},
|
|
Forget {
|
|
url: String,
|
|
},
|
|
SetAutoConnect(bool),
|
|
SetStartFullscreen(bool),
|
|
OpenExternal(String),
|
|
/// Look at the Codeman on this Mac (setup::inspect): `quick` skips the
|
|
/// login shell, `deep` also asks its interactive profile.
|
|
LocalStatus {
|
|
quick: bool,
|
|
deep: bool,
|
|
},
|
|
/// Run the installer for this Mac in Terminal.
|
|
InstallLocal {
|
|
network: bool,
|
|
},
|
|
/// Run the installer on another machine over SSH, in Terminal. `target`
|
|
/// is what the user typed; setup::SshTarget::parse checks it.
|
|
InstallRemote {
|
|
target: String,
|
|
},
|
|
/// Put the install one-liner on the clipboard.
|
|
CopyInstallCommand,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum Ipc {
|
|
Host(HostCmd),
|
|
Connect(ConnectCmd),
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(tag = "cmd", rename_all = "camelCase", deny_unknown_fields)]
|
|
enum RawConnect {
|
|
Ready,
|
|
Connect {
|
|
url: String,
|
|
#[serde(default)]
|
|
username: Option<String>,
|
|
#[serde(default)]
|
|
password: Option<String>,
|
|
#[serde(default)]
|
|
remember: bool,
|
|
},
|
|
Cancel,
|
|
StartServer {
|
|
url: String,
|
|
},
|
|
Forget {
|
|
url: String,
|
|
},
|
|
SetAutoConnect {
|
|
value: bool,
|
|
},
|
|
SetStartFullscreen {
|
|
value: bool,
|
|
},
|
|
OpenExternal {
|
|
url: String,
|
|
},
|
|
LocalStatus {
|
|
#[serde(default)]
|
|
quick: bool,
|
|
#[serde(default)]
|
|
deep: bool,
|
|
},
|
|
InstallLocal {
|
|
#[serde(default)]
|
|
network: bool,
|
|
},
|
|
InstallRemote {
|
|
target: String,
|
|
},
|
|
CopyInstallCommand,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(tag = "cmd", rename_all = "camelCase", deny_unknown_fields)]
|
|
enum RawHost {
|
|
OpenWindow { url: String },
|
|
CloseWindow,
|
|
FocusWindow,
|
|
OpenExternal { url: String },
|
|
SessionExpired,
|
|
}
|
|
|
|
fn bounded(s: Option<String>, max: usize) -> Option<Option<String>> {
|
|
match s {
|
|
Some(v) if v.len() > max => None,
|
|
other => Some(other),
|
|
}
|
|
}
|
|
|
|
/// The bundled connect page's frame (assets.rs serves it at `/` and
|
|
/// `/index.html`; the query, such as `?manual=1`, does not matter).
|
|
fn is_connect_page(frame: &Url) -> bool {
|
|
frame.scheme() == APP_SCHEME && frame.host_str() == Some("localhost") && matches!(frame.path(), "/" | "/index.html")
|
|
}
|
|
|
|
/// Parse and gate a message from a page. `frame_url` is the URL of the frame
|
|
/// that sent it (wry reports it from WebKit's frame info, so a page cannot
|
|
/// forge it), `is_main_window` whether it came from the main window.
|
|
///
|
|
/// - The connect page is listened to only in the main window and only while it
|
|
/// is still the live document (Connect, or Loading before the server page
|
|
/// commits).
|
|
/// - A server page is listened to only once it committed, and only from a
|
|
/// frame running Codeman's own app shell (trusted_frame).
|
|
pub fn parse_ipc(frame_url: &str, body: &str, phase: &Phase, is_main_window: bool) -> Option<Ipc> {
|
|
if body.len() > 16 * 1024 {
|
|
return None;
|
|
}
|
|
let frame = Url::parse(frame_url).ok()?;
|
|
if frame.scheme() == APP_SCHEME {
|
|
if !is_main_window || !phase.connect_page_live() || !is_connect_page(&frame) {
|
|
return None;
|
|
}
|
|
let cmd = match serde_json::from_str::<RawConnect>(body).ok()? {
|
|
RawConnect::Ready => ConnectCmd::Ready,
|
|
RawConnect::Connect {
|
|
url,
|
|
username,
|
|
password,
|
|
remember,
|
|
} => {
|
|
if url.len() > MAX_URL_LEN {
|
|
return None;
|
|
}
|
|
ConnectCmd::Connect {
|
|
url,
|
|
username: bounded(username, MAX_FIELD_LEN)?,
|
|
password: bounded(password, MAX_FIELD_LEN)?,
|
|
remember,
|
|
}
|
|
}
|
|
RawConnect::Cancel => ConnectCmd::Cancel,
|
|
RawConnect::StartServer { url } if url.len() <= MAX_URL_LEN => ConnectCmd::StartServer { url },
|
|
RawConnect::Forget { url } if url.len() <= MAX_URL_LEN => ConnectCmd::Forget { url },
|
|
RawConnect::SetAutoConnect { value } => ConnectCmd::SetAutoConnect(value),
|
|
RawConnect::SetStartFullscreen { value } => ConnectCmd::SetStartFullscreen(value),
|
|
RawConnect::OpenExternal { url } if url.len() <= MAX_URL_LEN => ConnectCmd::OpenExternal(url),
|
|
RawConnect::LocalStatus { quick, deep } => ConnectCmd::LocalStatus { quick, deep },
|
|
RawConnect::InstallLocal { network } => ConnectCmd::InstallLocal { network },
|
|
RawConnect::InstallRemote { target } if target.len() <= MAX_FIELD_LEN => {
|
|
ConnectCmd::InstallRemote { target }
|
|
}
|
|
RawConnect::CopyInstallCommand => ConnectCmd::CopyInstallCommand,
|
|
_ => return None,
|
|
};
|
|
return Some(Ipc::Connect(cmd));
|
|
}
|
|
let srv = phase.loaded()?;
|
|
if !trusted_frame(&frame, srv) {
|
|
return None;
|
|
}
|
|
let cmd = match serde_json::from_str::<RawHost>(body).ok()? {
|
|
RawHost::OpenWindow { url } if url.len() <= MAX_URL_LEN => HostCmd::OpenWindow(url),
|
|
RawHost::CloseWindow => HostCmd::CloseWindow,
|
|
RawHost::FocusWindow => HostCmd::FocusWindow,
|
|
RawHost::OpenExternal { url } if url.len() <= MAX_URL_LEN => HostCmd::OpenExternal(url),
|
|
RawHost::SessionExpired => HostCmd::SessionExpired,
|
|
_ => return None,
|
|
};
|
|
Some(Ipc::Host(cmd))
|
|
}
|
|
|
|
/// Only http(s) URLs ever reach the default browser (never `file:`, a custom
|
|
/// app scheme, or anything `open` would hand to another program).
|
|
pub fn external_url(target: &str) -> Option<Url> {
|
|
let url = Url::parse(target).ok()?;
|
|
matches!(url.scheme(), "http" | "https").then_some(url)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::time::Duration;
|
|
|
|
fn u(s: &str) -> Url {
|
|
Url::parse(s).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_adds_scheme_and_trailing_slash() {
|
|
assert_eq!(
|
|
normalize_server_url("127.0.0.1:3000").unwrap().as_str(),
|
|
"http://127.0.0.1:3000/"
|
|
);
|
|
assert_eq!(
|
|
normalize_server_url(" localhost:3000 ").unwrap().as_str(),
|
|
"http://localhost:3000/"
|
|
);
|
|
assert_eq!(
|
|
normalize_server_url("box.tailf80371.ts.net").unwrap().as_str(),
|
|
"https://box.tailf80371.ts.net/"
|
|
);
|
|
assert_eq!(
|
|
normalize_server_url("Box.Tailf80371.TS.NET/codeman").unwrap().as_str(),
|
|
"https://box.tailf80371.ts.net/codeman/"
|
|
);
|
|
// With a port it is Codeman's own listener, not tailscale serve.
|
|
assert_eq!(
|
|
normalize_server_url("box.tailf80371.ts.net:3000").unwrap().as_str(),
|
|
"http://box.tailf80371.ts.net:3000/"
|
|
);
|
|
assert_eq!(
|
|
normalize_server_url("box.tailf80371.ts.net:8443/x").unwrap().as_str(),
|
|
"http://box.tailf80371.ts.net:8443/x/"
|
|
);
|
|
// A scheme that was typed is never second-guessed.
|
|
assert_eq!(
|
|
normalize_server_url("https://box.tailf80371.ts.net:8443")
|
|
.unwrap()
|
|
.as_str(),
|
|
"https://box.tailf80371.ts.net:8443/"
|
|
);
|
|
assert_eq!(
|
|
normalize_server_url("https://host/codeman").unwrap().as_str(),
|
|
"https://host/codeman/"
|
|
);
|
|
assert_eq!(
|
|
normalize_server_url("http://host:3000/?x=1#y").unwrap().as_str(),
|
|
"http://host:3000/"
|
|
);
|
|
assert_eq!(
|
|
normalize_server_url("[::1]:3000").unwrap().as_str(),
|
|
"http://[::1]:3000/"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_rejects_bad_input() {
|
|
assert!(normalize_server_url("").is_err());
|
|
assert!(normalize_server_url("ftp://host").is_err());
|
|
assert!(normalize_server_url("file:///etc/passwd").is_err());
|
|
assert!(normalize_server_url("http://admin:pw@host:3000").is_err());
|
|
assert!(normalize_server_url(&format!("http://{}", "a".repeat(5000))).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn origin_and_loopback() {
|
|
assert!(same_origin(
|
|
&u("http://127.0.0.1:3000/"),
|
|
&u("http://127.0.0.1:3000/api/x")
|
|
));
|
|
assert!(!same_origin(&u("http://127.0.0.1:3000/"), &u("http://127.0.0.1:3001/")));
|
|
assert!(!same_origin(
|
|
&u("http://127.0.0.1:3000/"),
|
|
&u("https://127.0.0.1:3000/")
|
|
));
|
|
assert!(same_origin(&u("https://host/"), &u("https://host:443/")));
|
|
assert!(is_loopback(&u("http://localhost:3000/")));
|
|
assert!(is_loopback(&u("http://127.0.0.5:3000/")));
|
|
assert!(is_loopback(&u("http://[::1]:3000/")));
|
|
assert!(!is_loopback(&u("http://192.168.1.2:3000/")));
|
|
assert!(!is_loopback(&u("http://evil.localhost:3000/")));
|
|
}
|
|
|
|
#[test]
|
|
fn start_locally_only_where_the_daemon_binds() {
|
|
assert!(can_start_locally(&u("http://127.0.0.1:3000/")));
|
|
assert!(can_start_locally(&u("http://localhost:3000/")));
|
|
assert!(can_start_locally(&u("http://LOCALHOST:3000/codeman/")));
|
|
// Loopback, but `codeman web -d` binds 127.0.0.1 only.
|
|
assert!(!can_start_locally(&u("http://127.0.0.2:3000/")));
|
|
assert!(!can_start_locally(&u("http://[::1]:3000/")));
|
|
assert!(!can_start_locally(&u("https://127.0.0.1:3000/")));
|
|
assert!(!can_start_locally(&u("https://localhost:3000/")));
|
|
assert!(!can_start_locally(&u("http://box.local:3000/")));
|
|
assert!(!can_start_locally(&u("http://evil.localhost:3000/")));
|
|
}
|
|
|
|
#[test]
|
|
fn start_args_carry_port_and_base_path() {
|
|
assert_eq!(
|
|
start_args(&u("http://127.0.0.1:3000/")),
|
|
vec!["web", "-d", "--port", "3000"]
|
|
);
|
|
assert_eq!(start_args(&u("http://localhost/")), vec!["web", "-d", "--port", "80"]);
|
|
assert_eq!(
|
|
start_args(&u("http://127.0.0.1:4100/codeman/")),
|
|
vec!["web", "-d", "--port", "4100", "--base-url", "/codeman"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn phase_accessors() {
|
|
let srv = u("http://127.0.0.1:3000/");
|
|
assert_eq!(Phase::Connect.server(), None);
|
|
assert_eq!(Phase::Connect.loaded(), None);
|
|
assert!(Phase::Connect.connect_page_live());
|
|
assert_eq!(Phase::Loading(srv.clone()).server(), Some(&srv));
|
|
assert_eq!(Phase::Loading(srv.clone()).loaded(), None);
|
|
assert!(Phase::Loading(srv.clone()).connect_page_live());
|
|
assert_eq!(Phase::Loaded(srv.clone()).server(), Some(&srv));
|
|
assert_eq!(Phase::Loaded(srv.clone()).loaded(), Some(&srv));
|
|
assert!(!Phase::Loaded(srv).connect_page_live());
|
|
}
|
|
|
|
#[test]
|
|
fn navigation_policy() {
|
|
let srv = u("http://127.0.0.1:3000/");
|
|
let loaded = Phase::Loaded(srv.clone());
|
|
assert_eq!(
|
|
navigation("http://127.0.0.1:3000/session/abc", &loaded),
|
|
Navigation::Allow
|
|
);
|
|
assert_eq!(navigation("about:blank", &loaded), Navigation::Allow);
|
|
assert_eq!(navigation("about:srcdoc", &loaded), Navigation::Allow);
|
|
assert_eq!(navigation("blob:http://127.0.0.1:3000/x", &loaded), Navigation::Allow);
|
|
assert_eq!(navigation("https://example.com/", &loaded), Navigation::Block);
|
|
assert_eq!(navigation("http://127.0.0.1:3001/", &loaded), Navigation::Block);
|
|
assert_eq!(navigation("file:///etc/passwd", &loaded), Navigation::Block);
|
|
assert_eq!(navigation("not a url", &loaded), Navigation::Block);
|
|
// The connect page until a server page committed.
|
|
assert_eq!(navigation(CONNECT_PAGE, &Phase::Connect), Navigation::Allow);
|
|
assert_eq!(navigation(CONNECT_PAGE, &loaded), Navigation::Block);
|
|
// Disconnected: no web origin is trusted yet.
|
|
assert_eq!(navigation("http://127.0.0.1:3000/", &Phase::Connect), Navigation::Block);
|
|
}
|
|
|
|
#[test]
|
|
fn navigation_while_loading() {
|
|
let loading = Phase::Loading(u("http://127.0.0.1:3000/codeman/"));
|
|
// The load itself, and the connect page the app may go back to.
|
|
assert_eq!(
|
|
navigation("http://127.0.0.1:3000/codeman/", &loading),
|
|
Navigation::Allow
|
|
);
|
|
assert_eq!(navigation(CONNECT_PAGE, &loading), Navigation::Allow);
|
|
assert_eq!(
|
|
navigation(&format!("{CONNECT_PAGE}?manual=1"), &loading),
|
|
Navigation::Allow
|
|
);
|
|
assert_eq!(navigation("http://127.0.0.1:3001/", &loading), Navigation::Block);
|
|
assert_eq!(navigation("https://example.com/", &loading), Navigation::Block);
|
|
}
|
|
|
|
#[test]
|
|
fn new_window_policy() {
|
|
let srv = u("https://box.tailnet.ts.net/");
|
|
let loaded = Phase::Loaded(srv.clone());
|
|
let loading = Phase::Loading(srv);
|
|
let long = format!("https://github.com/{}", "a".repeat(5000));
|
|
// The gates before the pop-out check hold whoever asked.
|
|
for via in [Via::Host, Via::Script] {
|
|
// window.open('') would get a window with none of the app's handlers.
|
|
assert_eq!(new_window("", &loaded, via), NewWindow::Deny);
|
|
assert_eq!(new_window("about:blank", &loaded, via), NewWindow::Deny);
|
|
assert_eq!(
|
|
new_window("https://github.com/", &loaded, via),
|
|
NewWindow::External(u("https://github.com/"))
|
|
);
|
|
// Same origin but not a pop-out target: denied, never the browser.
|
|
assert_eq!(
|
|
new_window("https://box.tailnet.ts.net/webview/cap/x", &loaded, via),
|
|
NewWindow::Deny
|
|
);
|
|
assert_eq!(new_window("https://box.tailnet.ts.net/", &loaded, via), NewWindow::Deny);
|
|
assert_eq!(new_window("javascript:alert(1)", &loaded, via), NewWindow::Deny);
|
|
assert_eq!(new_window("file:///tmp/x", &loaded, via), NewWindow::Deny);
|
|
assert_eq!(new_window("mailto:a@b.c", &loaded, via), NewWindow::Deny);
|
|
assert_eq!(
|
|
new_window("blob:https://box.tailnet.ts.net/x", &loaded, via),
|
|
NewWindow::Deny
|
|
);
|
|
assert_eq!(new_window(&long, &loaded, via), NewWindow::Deny);
|
|
// Nothing opens before a server page committed.
|
|
assert_eq!(new_window("https://github.com/", &Phase::Connect, via), NewWindow::Deny);
|
|
assert_eq!(new_window("https://github.com/", &loading, via), NewWindow::Deny);
|
|
assert_eq!(
|
|
new_window("https://box.tailnet.ts.net/session/x", &loading, via),
|
|
NewWindow::Deny
|
|
);
|
|
assert_eq!(
|
|
new_window("https://box.tailnet.ts.net/api/download?path=x", &loading, via),
|
|
NewWindow::Deny
|
|
);
|
|
}
|
|
assert_eq!(
|
|
new_window("https://box.tailnet.ts.net/session/x", &loaded, Via::Host),
|
|
NewWindow::AppWindow(u("https://box.tailnet.ts.net/session/x"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn new_window_by_who_asked() {
|
|
let loaded = Phase::Loaded(u("http://127.0.0.1:3000/"));
|
|
let script = |s: &str| new_window(s, &loaded, Via::Script);
|
|
let host = |s: &str| new_window(s, &loaded, Via::Host);
|
|
// What Codeman itself opens with window.open: either way.
|
|
for s in [
|
|
"http://127.0.0.1:3000/api/sessions/s1/file-raw?path=src%2Fa.png",
|
|
"http://127.0.0.1:3000/api/sessions/s1/file-raw?path=a.txt&download=true",
|
|
"http://127.0.0.1:3000/api/sessions/s1/file-preview?path=a.docx",
|
|
"http://127.0.0.1:3000/api/sessions/s1/attachments/att1/raw",
|
|
"http://127.0.0.1:3000/api/sessions/s1/attachments/att1/raw?download=true",
|
|
"http://127.0.0.1:3000/api/sessions/s1/attachments/att1/preview",
|
|
"http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/a.png/raw",
|
|
"http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/my%20notes.md/preview",
|
|
"http://127.0.0.1:3000/api/download?path=x",
|
|
"http://127.0.0.1:3000/api/filesystem/preview?path=%2Fhome%2Fu%2Fa.md",
|
|
] {
|
|
assert_eq!(script(s), NewWindow::AppWindow(u(s)), "{s}");
|
|
assert_eq!(host(s), NewWindow::AppWindow(u(s)), "{s}");
|
|
}
|
|
// What Codeman opens only through CodemanHost: refused to a script,
|
|
// in any spelling the pop-out check accepts.
|
|
for s in [
|
|
"http://127.0.0.1:3000/session/abc",
|
|
"http://127.0.0.1:3000/%73ession/abc",
|
|
"http://127.0.0.1:3000/html-view/cap/index.html",
|
|
"http://127.0.0.1:3000/html-view/cap/sub/page.html",
|
|
"http://127.0.0.1:3000/%68tml-view/cap/index.html",
|
|
] {
|
|
assert_eq!(script(s), NewWindow::Deny, "{s}");
|
|
assert_eq!(host(s), NewWindow::AppWindow(u(s)), "{s}");
|
|
}
|
|
// Not a pop-out target for either.
|
|
for s in [
|
|
"http://127.0.0.1:3000/webview/cap/x",
|
|
"http://127.0.0.1:3000/api/sessions/s1/input",
|
|
"http://127.0.0.1:3000/api/download/x",
|
|
"http://127.0.0.1:3000/x/session/abc",
|
|
] {
|
|
assert_eq!(script(s), NewWindow::Deny, "{s}");
|
|
assert_eq!(host(s), NewWindow::Deny, "{s}");
|
|
}
|
|
|
|
let based = Phase::Loaded(u("https://host/codeman/"));
|
|
assert_eq!(
|
|
new_window(
|
|
"https://host/codeman/api/sessions/s1/file-raw?path=a",
|
|
&based,
|
|
Via::Script
|
|
),
|
|
NewWindow::AppWindow(u("https://host/codeman/api/sessions/s1/file-raw?path=a"))
|
|
);
|
|
assert_eq!(
|
|
new_window("https://host/api/download?path=a", &based, Via::Script),
|
|
NewWindow::Deny
|
|
);
|
|
assert_eq!(
|
|
new_window("https://host/codeman/session/abc", &based, Via::Script),
|
|
NewWindow::Deny
|
|
);
|
|
assert_eq!(
|
|
new_window("https://host/codeman/session/abc", &based, Via::Host),
|
|
NewWindow::AppWindow(u("https://host/codeman/session/abc"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn challenges_in_scope() {
|
|
let srv = u("http://127.0.0.1:3000/");
|
|
let ours = |s: &str| challenge_in_scope(&u(s), &srv);
|
|
// Codeman's own pages, API and assets.
|
|
assert!(ours("http://127.0.0.1:3000/"));
|
|
assert!(ours("http://127.0.0.1:3000/index.html"));
|
|
assert!(ours("http://127.0.0.1:3000/app.js?v=1"));
|
|
assert!(ours("http://127.0.0.1:3000/api/status"));
|
|
assert!(ours("http://127.0.0.1:3000/session/abc"));
|
|
assert!(ours(
|
|
"http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/50%25.txt/raw"
|
|
));
|
|
// `webview` with no capability below it is not the proxy.
|
|
assert!(ours("http://127.0.0.1:3000/webview"));
|
|
assert!(ours("http://127.0.0.1:3000/webviewx/cap/x"));
|
|
// Decoded once: `%2577ebview` reaches `%77ebview`, not the proxy.
|
|
assert!(ours("http://127.0.0.1:3000/%2577ebview/cap/x"));
|
|
|
|
// A proxied dashboard's relayed 401, in any spelling the router decodes.
|
|
assert!(!ours("http://127.0.0.1:3000/webview/cap/x"));
|
|
assert!(!ours("http://127.0.0.1:3000/webview/cap/"));
|
|
assert!(!ours("http://127.0.0.1:3000/%77ebview/cap/x"));
|
|
assert!(!ours("http://127.0.0.1:3000/%77%65%62view/cap/x"));
|
|
// A rendered HTML file.
|
|
assert!(!ours("http://127.0.0.1:3000/html-view/c/x"));
|
|
assert!(!ours("http://127.0.0.1:3000/%68tml-view/c/x"));
|
|
// A path that does not decode.
|
|
assert!(!ours("http://127.0.0.1:3000/a%zz"));
|
|
assert!(!ours("http://127.0.0.1:3000/session/%ff"));
|
|
// Another origin.
|
|
assert!(!ours("http://127.0.0.1:3001/"));
|
|
assert!(!ours("https://127.0.0.1:3000/"));
|
|
assert!(!ours("http://localhost:3000/"));
|
|
assert!(!ours("https://evil.example/"));
|
|
|
|
let based = u("https://host/codeman/");
|
|
let ours_based = |s: &str| challenge_in_scope(&u(s), &based);
|
|
assert!(ours_based("https://host/codeman/"));
|
|
assert!(ours_based("https://host/codeman/api/status"));
|
|
assert!(ours_based("https://host:443/codeman/session/abc"));
|
|
assert!(!ours_based("https://host/codeman/webview/cap/x"));
|
|
assert!(!ours_based("https://host/codeman/%77ebview/cap/x"));
|
|
assert!(!ours_based("https://host/codeman/html-view/c/x"));
|
|
// Siblings outside the base, and Codeman answering unprefixed.
|
|
assert!(!ours_based("https://host/grafana/"));
|
|
assert!(!ours_based("https://host/grafana/api/x"));
|
|
assert!(!ours_based("https://host/codemanx/"));
|
|
assert!(!ours_based("https://host/codeman"));
|
|
assert!(!ours_based("https://host/"));
|
|
assert!(!ours_based("https://host/webview/cap/x"));
|
|
assert!(!ours_based("https://host/%63odeman/api/status"));
|
|
assert!(!ours_based("http://host/codeman/"));
|
|
}
|
|
|
|
#[test]
|
|
fn session_ids_of_popouts() {
|
|
let srv = u("http://127.0.0.1:3000/");
|
|
let id = |s: &str| session_id_of(&u(s), &srv);
|
|
assert_eq!(id("http://127.0.0.1:3000/session/abc").as_deref(), Some("abc"));
|
|
assert_eq!(id("http://127.0.0.1:3000/session/abc?x=1#y").as_deref(), Some("abc"));
|
|
assert_eq!(
|
|
id("http://127.0.0.1:3000/session/0b7f6c2e-1").as_deref(),
|
|
Some("0b7f6c2e-1")
|
|
);
|
|
// Decoded once, as is_popout_target and the server's router read it.
|
|
assert_eq!(id("http://127.0.0.1:3000/%73ession/abc").as_deref(), Some("abc"));
|
|
assert_eq!(id("http://127.0.0.1:3000/session/a%62c").as_deref(), Some("abc"));
|
|
assert_eq!(id("http://127.0.0.1:3000/session/a%20b").as_deref(), Some("a b"));
|
|
// Only exactly `session/<id>` below the base.
|
|
assert_eq!(id("http://127.0.0.1:3000/x/session/abc"), None);
|
|
assert_eq!(id("http://127.0.0.1:3000/session/abc/"), None);
|
|
assert_eq!(id("http://127.0.0.1:3000/session/abc/x"), None);
|
|
assert_eq!(id("http://127.0.0.1:3000/session/"), None);
|
|
assert_eq!(id("http://127.0.0.1:3000/session/a%2Fb"), None);
|
|
assert_eq!(id("http://127.0.0.1:3000/session/%2e%2e"), None);
|
|
assert_eq!(id("http://127.0.0.1:3000/session/a%2525b"), None);
|
|
assert_eq!(id("http://127.0.0.1:3000/Session/abc"), None);
|
|
assert_eq!(id("http://127.0.0.1:3000/webview/cap/session/abc"), None);
|
|
// Pop-out targets of the other kinds.
|
|
assert_eq!(id("http://127.0.0.1:3000/html-view/c/session/abc"), None);
|
|
assert_eq!(id("http://127.0.0.1:3000/api/sessions/abc/file-raw?path=a"), None);
|
|
assert_eq!(id("http://127.0.0.1:3000/api/download?path=a"), None);
|
|
// Other origins and userinfo.
|
|
assert_eq!(id("http://127.0.0.1:3001/session/abc"), None);
|
|
assert_eq!(id("http://admin:pw@127.0.0.1:3000/session/abc"), None);
|
|
|
|
let based = u("https://host/codeman/");
|
|
let idb = |s: &str| session_id_of(&u(s), &based);
|
|
assert_eq!(idb("https://host/codeman/session/abc").as_deref(), Some("abc"));
|
|
assert_eq!(idb("https://host/session/abc"), None);
|
|
assert_eq!(idb("https://host/grafana/session/abc"), None);
|
|
assert_eq!(idb("https://host/codeman/x/session/abc"), None);
|
|
// Agrees with is_popout_target on every session-shaped URL above.
|
|
for s in [
|
|
"http://127.0.0.1:3000/session/abc",
|
|
"http://127.0.0.1:3000/%73ession/abc",
|
|
"http://127.0.0.1:3000/x/session/abc",
|
|
"http://127.0.0.1:3000/session/a%2Fb",
|
|
] {
|
|
let url = u(s);
|
|
assert_eq!(session_id_of(&url, &srv).is_some(), is_popout_target(&url, &srv), "{s}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn popout_targets_are_codemans_own_pages() {
|
|
let srv = u("http://127.0.0.1:3000/");
|
|
let ok = |s: &str| is_popout_target(&u(s), &srv);
|
|
assert!(ok("http://127.0.0.1:3000/session/0b7f6c2e-1"));
|
|
assert!(ok("http://127.0.0.1:3000/session/abc?x=1#y"));
|
|
assert!(ok("http://127.0.0.1:3000/html-view/cap/index.html"));
|
|
assert!(ok("http://127.0.0.1:3000/html-view/cap/sub/page.html"));
|
|
assert!(ok("http://127.0.0.1:3000/api/sessions/s1/file-raw?path=src%2Fa.png"));
|
|
assert!(ok(
|
|
"http://127.0.0.1:3000/api/sessions/s1/file-raw?path=a.txt&download=true"
|
|
));
|
|
assert!(ok("http://127.0.0.1:3000/api/sessions/s1/file-preview?path=a.docx"));
|
|
assert!(ok("http://127.0.0.1:3000/api/sessions/s1/attachments/att1/raw"));
|
|
assert!(ok("http://127.0.0.1:3000/api/sessions/s1/attachments/att1/preview"));
|
|
assert!(ok("http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/a.png/raw"));
|
|
assert!(ok(
|
|
"http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/my%20notes.md/preview"
|
|
));
|
|
assert!(ok("http://127.0.0.1:3000/api/download?path=x"));
|
|
// The path picker's preview, which its Open link shows in a window.
|
|
assert!(ok(
|
|
"http://127.0.0.1:3000/api/filesystem/preview?path=%2Fhome%2Fu%2Fa.md&sessionId=s1"
|
|
));
|
|
// A single decode is how the server's router sees the path.
|
|
assert!(ok("http://127.0.0.1:3000/%73ession/abc"));
|
|
|
|
// Proxied dashboards, in any spelling.
|
|
assert!(!ok("http://127.0.0.1:3000/webview/cap/x"));
|
|
assert!(!ok("http://127.0.0.1:3000/webview/cap/"));
|
|
assert!(!ok("http://127.0.0.1:3000/%77ebview/cap/x"));
|
|
assert!(!ok("http://127.0.0.1:3000/%2577ebview/cap/x"));
|
|
// Traversal: the url crate resolves `%2e%2e` itself; encoded slashes
|
|
// and double encoding are refused after the decode.
|
|
assert!(!ok("http://127.0.0.1:3000/session/%2e%2e/webview/cap/"));
|
|
assert!(!ok("http://127.0.0.1:3000/html-view/c/%2e%2e/%2e%2e/webview/cap/"));
|
|
assert!(!ok("http://127.0.0.1:3000/html-view%2F..%2Fwebview%2Fcap%2F"));
|
|
assert!(!ok("http://127.0.0.1:3000/html-view/c/%252e%252e/x"));
|
|
assert!(!ok("http://127.0.0.1:3000/html-view/c/..%5C..%5Cwebview%5Cx"));
|
|
assert!(!ok("http://127.0.0.1:3000/session/a%00b"));
|
|
assert!(!ok("http://127.0.0.1:3000/session/a%zzb"));
|
|
assert!(!ok("http://127.0.0.1:3000/session/a%2"));
|
|
assert!(!ok("http://127.0.0.1:3000/session/%ff"));
|
|
// Near misses of the allowlist.
|
|
assert!(!ok("http://127.0.0.1:3000/"));
|
|
assert!(!ok("http://127.0.0.1:3000/index.html"));
|
|
assert!(!ok("http://127.0.0.1:3000/session/"));
|
|
assert!(!ok("http://127.0.0.1:3000/session/abc/"));
|
|
assert!(!ok("http://127.0.0.1:3000/session/a%2Fb"));
|
|
assert!(!ok("http://127.0.0.1:3000/Session/abc"));
|
|
assert!(!ok("http://127.0.0.1:3000/html-view/"));
|
|
assert!(!ok("http://127.0.0.1:3000/api/sessions"));
|
|
assert!(!ok("http://127.0.0.1:3000/api/sessions/s1/input"));
|
|
assert!(!ok("http://127.0.0.1:3000/api/sessions//file-raw"));
|
|
assert!(!ok("http://127.0.0.1:3000/api/sessions/s1/attachments/att1/delete"));
|
|
assert!(!ok("http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/"));
|
|
assert!(!ok("http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/a.png"));
|
|
assert!(!ok(
|
|
"http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/a.png/thumbnail"
|
|
));
|
|
assert!(!ok(
|
|
"http://127.0.0.1:3000/api/sessions/s1/folders/f1/files/a.png/raw/x"
|
|
));
|
|
assert!(!ok("http://127.0.0.1:3000/api/download/x"));
|
|
// The picker's other routes list and create folders: never a window.
|
|
assert!(!ok("http://127.0.0.1:3000/api/filesystem/browse?path=%2F"));
|
|
assert!(!ok("http://127.0.0.1:3000/api/filesystem/mkdir"));
|
|
assert!(!ok("http://127.0.0.1:3000/api/filesystem/preview/x"));
|
|
assert!(!ok("http://127.0.0.1:3000/api/status"));
|
|
// Other origins and userinfo.
|
|
assert!(!ok("http://127.0.0.1:3001/session/abc"));
|
|
assert!(!ok("https://127.0.0.1:3000/session/abc"));
|
|
assert!(!ok("http://admin:pw@127.0.0.1:3000/session/abc"));
|
|
}
|
|
|
|
#[test]
|
|
fn popout_targets_below_a_base_path() {
|
|
let srv = u("https://host/codeman/");
|
|
let ok = |s: &str| is_popout_target(&u(s), &srv);
|
|
assert!(ok("https://host/codeman/session/abc"));
|
|
assert!(ok("https://host/codeman/html-view/c/x.html"));
|
|
assert!(ok("https://host/codeman/api/sessions/s1/attachments/a1/raw"));
|
|
// Outside the base: another app on the same origin, or Codeman's
|
|
// routes answering unprefixed.
|
|
assert!(!ok("https://host/session/abc"));
|
|
assert!(!ok("https://host/grafana/session/abc"));
|
|
assert!(!ok("https://host/codemanx/session/abc"));
|
|
assert!(!ok("https://host/codeman/../webview/c/"));
|
|
assert!(!ok("https://host/codeman/%2e%2e/webview/c/"));
|
|
assert!(!ok("https://host/%63odeman/session/abc"));
|
|
assert!(!ok("https://host/codeman/webview/c/x"));
|
|
assert!(!ok("https://host/codeman/%77ebview/c/x"));
|
|
}
|
|
|
|
#[test]
|
|
fn trusted_frames() {
|
|
let srv = u("http://127.0.0.1:3000/");
|
|
let t = |s: &str| trusted_frame(&u(s), &srv);
|
|
assert!(t("http://127.0.0.1:3000/"));
|
|
assert!(t("http://127.0.0.1:3000/?x=1"));
|
|
assert!(t("http://127.0.0.1:3000/index.html"));
|
|
assert!(t("http://127.0.0.1:3000/session/abc"));
|
|
assert!(t("http://127.0.0.1:3000/session/abc#frag"));
|
|
// The server routes the decoded path: any `%` is untrusted.
|
|
assert!(!t("http://127.0.0.1:3000/%73ession/abc"));
|
|
assert!(!t("http://127.0.0.1:3000/%68tml-view/c/x.html"));
|
|
assert!(!t("http://127.0.0.1:3000/%77ebview/c/"));
|
|
assert!(!t("http://127.0.0.1:3000/session/a%62c"));
|
|
assert!(!t("http://127.0.0.1:3000/html-view/c/x.html"));
|
|
assert!(!t("http://127.0.0.1:3000/webview/c/"));
|
|
assert!(!t("http://127.0.0.1:3000/some/relayed/dashboard/path"));
|
|
assert!(!t("http://127.0.0.1:3000/session/"));
|
|
assert!(!t("http://127.0.0.1:3000/session/abc/x"));
|
|
assert!(!t("http://127.0.0.1:3000/api/status"));
|
|
assert!(!t("http://127.0.0.1:3001/"));
|
|
assert!(!t("https://127.0.0.1:3000/"));
|
|
assert!(!t("codemanapp://localhost/index.html"));
|
|
|
|
let based = u("http://127.0.0.1:3000/codeman/");
|
|
let tb = |s: &str| trusted_frame(&u(s), &based);
|
|
assert!(tb("http://127.0.0.1:3000/codeman/"));
|
|
assert!(tb("http://127.0.0.1:3000/codeman/index.html"));
|
|
assert!(tb("http://127.0.0.1:3000/codeman/session/x"));
|
|
assert!(!tb("http://127.0.0.1:3000/codeman/html-view/c/"));
|
|
// Outside the base path is untrusted (fail closed).
|
|
assert!(!tb("http://127.0.0.1:3000/"));
|
|
assert!(!tb("http://127.0.0.1:3000/session/x"));
|
|
assert!(!tb("http://127.0.0.1:3000/codeman"));
|
|
assert!(!tb("http://127.0.0.1:3000/codemanx/"));
|
|
}
|
|
|
|
#[test]
|
|
fn authorization_stays_within_the_base_path() {
|
|
let srv = u("https://host/codeman/");
|
|
assert!(within_base(&u("https://host/codeman/"), &srv));
|
|
assert!(within_base(&u("https://host/codeman/session/x?y=1"), &srv));
|
|
assert!(!within_base(&u("https://host/codemanx/"), &srv));
|
|
assert!(!within_base(&u("https://host/grafana/"), &srv));
|
|
assert!(!within_base(&u("https://host/"), &srv));
|
|
assert!(!within_base(&u("https://host/codeman/../grafana/"), &srv));
|
|
// The same way out, spelled so that only a decoder sees it.
|
|
for escape in [
|
|
"https://host/codeman/..%2Fgrafana/",
|
|
"https://host/codeman/..%2fgrafana/x",
|
|
"https://host/codeman/%2e%2e%2Fgrafana/",
|
|
"https://host/codeman/..%5Cgrafana/",
|
|
"https://host/codeman/a/%2E%2E%2F%2E%2E%2Fgrafana/",
|
|
"https://host/codeman/session/a%zz",
|
|
] {
|
|
assert!(!within_base(&u(escape), &srv), "{escape}");
|
|
assert!(!challenge_in_scope(&u(escape), &srv), "{escape}");
|
|
}
|
|
// Encoded characters that are part of a name stay inside.
|
|
for inside in [
|
|
"https://host/codeman/api/sessions/s1/folders/f1/files/50%25.txt/raw",
|
|
"https://host/codeman/api/sessions/s1/file-raw?path=..%2Fx",
|
|
"https://host/codeman/session/a%20b",
|
|
"https://host/codeman/a..b/c",
|
|
] {
|
|
assert!(within_base(&u(inside), &srv), "{inside}");
|
|
}
|
|
assert!(!within_base(&u("http://host/codeman/"), &srv));
|
|
assert!(!within_base(&u("https://other/codeman/"), &srv));
|
|
let root = u("http://127.0.0.1:3000/");
|
|
assert!(within_base(&u("http://127.0.0.1:3000/anything"), &root));
|
|
assert!(!within_base(&u("http://127.0.0.1:3001/"), &root));
|
|
}
|
|
|
|
#[test]
|
|
fn webkit_safe_urls() {
|
|
// The url crate leaves these raw; pre-14 NSURL rejects them.
|
|
assert_eq!(
|
|
webkit_safe_url(&u("http://127.0.0.1:3000/a|b^c[d]/x?q={x}|^[]`\\")),
|
|
"http://127.0.0.1:3000/a%7Cb%5Ec%5Bd%5D/x?q=%7Bx%7D%7C%5E%5B%5D%60%5C"
|
|
);
|
|
assert_eq!(webkit_safe_url(&u("http://h/p#a{b}#c")), "http://h/p#a%7Bb%7D%23c");
|
|
// Host and port untouched: an IPv6 literal keeps its brackets.
|
|
assert_eq!(
|
|
webkit_safe_url(&u("http://[::1]:3000/x[1]?a[]=1")),
|
|
"http://[::1]:3000/x%5B1%5D?a%5B%5D=1"
|
|
);
|
|
// Existing escapes stay; a `%` that starts none is escaped itself.
|
|
assert_eq!(
|
|
webkit_safe_url(&u("http://h/%7Bx%7d?q=%7B%41")),
|
|
"http://h/%7Bx%7d?q=%7B%41"
|
|
);
|
|
assert_eq!(webkit_safe_url(&u("http://h/a%zz?b=5%")), "http://h/a%25zz?b=5%25");
|
|
// Ordinary URLs pass through unchanged.
|
|
for s in [
|
|
CONNECT_PAGE,
|
|
"codemanapp://localhost/index.html?manual=1",
|
|
"http://127.0.0.1:3000/",
|
|
"https://box.tailnet.ts.net/codeman/session/abc",
|
|
"http://h/api/sessions/s/file-raw?path=src%2Fa%20b.png&download=true",
|
|
] {
|
|
assert_eq!(webkit_safe_url(&u(s)), s);
|
|
}
|
|
// The result still parses to the same resource.
|
|
let messy = u("http://h/a|b?q={x}#f[1]");
|
|
assert_eq!(u(&webkit_safe_url(&messy)).host_str(), Some("h"));
|
|
assert_eq!(u(&webkit_safe_url(&messy)).path(), "/a%7Cb");
|
|
}
|
|
|
|
#[test]
|
|
fn open_budget_bursts_then_refills() {
|
|
let t0 = Instant::now();
|
|
let mut b = OpenBudget::new();
|
|
assert!(b.allow(t0));
|
|
assert!(b.allow(t0));
|
|
assert!(b.allow(t0));
|
|
assert!(!b.allow(t0));
|
|
assert!(!b.allow(t0 + Duration::from_millis(500)));
|
|
assert!(b.allow(t0 + Duration::from_millis(1000)));
|
|
assert!(!b.allow(t0 + Duration::from_millis(1000)));
|
|
// A clock going backwards refills nothing.
|
|
assert!(!b.allow(t0));
|
|
assert!(b.allow(t0 + Duration::from_millis(2000)));
|
|
// A long pause refills to the burst, never beyond it.
|
|
let later = t0 + Duration::from_secs(60);
|
|
assert!(b.allow(later));
|
|
assert!(b.allow(later));
|
|
assert!(b.allow(later));
|
|
assert!(!b.allow(later));
|
|
}
|
|
|
|
#[test]
|
|
fn a_window_recovers_from_a_lost_content_process_once() {
|
|
// The first time, every window that shows a server page loads it again.
|
|
assert_eq!(recovery(true, true, false), Recovery::Reload);
|
|
assert_eq!(recovery(false, true, false), Recovery::Reload);
|
|
// The connect page is shown anew, which also ends a load under way.
|
|
assert_eq!(recovery(true, false, false), Recovery::ConnectPage { gave_up: false });
|
|
// A second time in a row never reloads: that would loop.
|
|
assert_eq!(recovery(true, true, true), Recovery::ConnectPage { gave_up: true });
|
|
assert_eq!(recovery(false, true, true), Recovery::Close);
|
|
assert_eq!(recovery(true, false, true), Recovery::Nothing);
|
|
for is_main in [true, false] {
|
|
for on_server in [true, false] {
|
|
assert_ne!(recovery(is_main, on_server, true), Recovery::Reload);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn basic_auth() {
|
|
assert_eq!(basic_auth_header("admin", "secret"), "Basic YWRtaW46c2VjcmV0");
|
|
}
|
|
|
|
#[test]
|
|
fn ipc_from_server_page() {
|
|
let srv = u("http://127.0.0.1:3000/");
|
|
let loaded = Phase::Loaded(srv.clone());
|
|
let main = "http://127.0.0.1:3000/";
|
|
assert_eq!(
|
|
parse_ipc(
|
|
main,
|
|
r#"{"cmd":"openWindow","url":"http://127.0.0.1:3000/session/1"}"#,
|
|
&loaded,
|
|
true
|
|
),
|
|
Some(Ipc::Host(HostCmd::OpenWindow("http://127.0.0.1:3000/session/1".into())))
|
|
);
|
|
assert_eq!(
|
|
parse_ipc(
|
|
"http://127.0.0.1:3000/session/1",
|
|
r#"{"cmd":"closeWindow"}"#,
|
|
&loaded,
|
|
false
|
|
),
|
|
Some(Ipc::Host(HostCmd::CloseWindow))
|
|
);
|
|
assert_eq!(
|
|
parse_ipc(
|
|
main,
|
|
r#"{"cmd":"openExternal","url":"https://github.com/"}"#,
|
|
&loaded,
|
|
true
|
|
),
|
|
Some(Ipc::Host(HostCmd::OpenExternal("https://github.com/".into())))
|
|
);
|
|
assert_eq!(
|
|
parse_ipc(main, r#"{"cmd":"sessionExpired"}"#, &loaded, true),
|
|
Some(Ipc::Host(HostCmd::SessionExpired))
|
|
);
|
|
assert_eq!(
|
|
parse_ipc(
|
|
"http://127.0.0.1:3000/session/1",
|
|
r#"{"cmd":"sessionExpired"}"#,
|
|
&loaded,
|
|
false
|
|
),
|
|
Some(Ipc::Host(HostCmd::SessionExpired))
|
|
);
|
|
// Untrusted surfaces, other origins, connect commands: refused.
|
|
for frame in [
|
|
"http://127.0.0.1:3000/html-view/c/x.html",
|
|
"http://127.0.0.1:3000/%68tml-view/c/x.html",
|
|
"http://127.0.0.1:3000/webview/c/",
|
|
"http://127.0.0.1:3000/%77ebview/c/",
|
|
"http://127.0.0.1:3000/some/relayed/dashboard/path",
|
|
"https://evil.example/",
|
|
"about:blank",
|
|
] {
|
|
assert_eq!(parse_ipc(frame, r#"{"cmd":"focusWindow"}"#, &loaded, true), None);
|
|
assert_eq!(parse_ipc(frame, r#"{"cmd":"sessionExpired"}"#, &loaded, true), None);
|
|
}
|
|
assert_eq!(
|
|
parse_ipc(main, r#"{"cmd":"startServer","url":"x"}"#, &loaded, true),
|
|
None
|
|
);
|
|
assert_eq!(parse_ipc(main, r#"{"cmd":"focusWindow"}"#, &Phase::Connect, true), None);
|
|
assert_eq!(
|
|
parse_ipc(main, r#"{"cmd":"runShell","line":"rm -rf ~"}"#, &loaded, true),
|
|
None
|
|
);
|
|
assert_eq!(parse_ipc(main, r#"{"cmd":"openWindow"}"#, &loaded, true), None);
|
|
assert_eq!(parse_ipc(main, "not json", &loaded, true), None);
|
|
// serde ignores extra keys on a no-argument command even with
|
|
// deny_unknown_fields; harmless, the command itself is still checked.
|
|
assert_eq!(
|
|
parse_ipc(main, r#"{"cmd":"focusWindow","extra":1}"#, &loaded, true),
|
|
Some(Ipc::Host(HostCmd::FocusWindow))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ipc_from_a_based_server_page() {
|
|
let loaded = Phase::Loaded(u("https://host/codeman/"));
|
|
assert_eq!(
|
|
parse_ipc("https://host/codeman/", r#"{"cmd":"focusWindow"}"#, &loaded, true),
|
|
Some(Ipc::Host(HostCmd::FocusWindow))
|
|
);
|
|
assert_eq!(
|
|
parse_ipc("https://host/grafana/", r#"{"cmd":"focusWindow"}"#, &loaded, true),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
parse_ipc("https://host/", r#"{"cmd":"focusWindow"}"#, &loaded, true),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ipc_while_loading() {
|
|
let loading = Phase::Loading(u("http://127.0.0.1:3000/"));
|
|
let page = "codemanapp://localhost/index.html";
|
|
// The connect page is still the document: it stays usable.
|
|
assert_eq!(
|
|
parse_ipc(page, r#"{"cmd":"cancel"}"#, &loading, true),
|
|
Some(Ipc::Connect(ConnectCmd::Cancel))
|
|
);
|
|
assert_eq!(
|
|
parse_ipc(page, r#"{"cmd":"connect","url":"127.0.0.1:3001"}"#, &loading, true),
|
|
Some(Ipc::Connect(ConnectCmd::Connect {
|
|
url: "127.0.0.1:3001".into(),
|
|
username: None,
|
|
password: None,
|
|
remember: false
|
|
}))
|
|
);
|
|
// The server page has not committed: nothing it says is heard yet.
|
|
assert_eq!(
|
|
parse_ipc("http://127.0.0.1:3000/", r#"{"cmd":"focusWindow"}"#, &loading, true),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
parse_ipc("http://127.0.0.1:3000/", r#"{"cmd":"sessionExpired"}"#, &loading, true),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
parse_ipc(
|
|
"http://127.0.0.1:3000/",
|
|
r#"{"cmd":"openExternal","url":"https://github.com/"}"#,
|
|
&loading,
|
|
true
|
|
),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ipc_install_commands() {
|
|
let page = "codemanapp://localhost/index.html";
|
|
let c = |body: &str| parse_ipc(page, body, &Phase::Connect, true);
|
|
assert_eq!(
|
|
c(r#"{"cmd":"localStatus","quick":true}"#),
|
|
Some(Ipc::Connect(ConnectCmd::LocalStatus {
|
|
quick: true,
|
|
deep: false
|
|
}))
|
|
);
|
|
assert_eq!(
|
|
c(r#"{"cmd":"localStatus","deep":true}"#),
|
|
Some(Ipc::Connect(ConnectCmd::LocalStatus {
|
|
quick: false,
|
|
deep: true
|
|
}))
|
|
);
|
|
assert_eq!(
|
|
c(r#"{"cmd":"localStatus"}"#),
|
|
Some(Ipc::Connect(ConnectCmd::LocalStatus {
|
|
quick: false,
|
|
deep: false
|
|
}))
|
|
);
|
|
assert_eq!(
|
|
c(r#"{"cmd":"installLocal","network":true}"#),
|
|
Some(Ipc::Connect(ConnectCmd::InstallLocal { network: true }))
|
|
);
|
|
assert_eq!(
|
|
c(r#"{"cmd":"installLocal"}"#),
|
|
Some(Ipc::Connect(ConnectCmd::InstallLocal { network: false }))
|
|
);
|
|
assert_eq!(
|
|
c(r#"{"cmd":"installRemote","target":"kay@box"}"#),
|
|
Some(Ipc::Connect(ConnectCmd::InstallRemote {
|
|
target: "kay@box".into()
|
|
}))
|
|
);
|
|
assert_eq!(
|
|
c(r#"{"cmd":"copyInstallCommand"}"#),
|
|
Some(Ipc::Connect(ConnectCmd::CopyInstallCommand))
|
|
);
|
|
// Nothing else rides along: no script, flags or command from the page.
|
|
assert_eq!(c(r#"{"cmd":"installLocal","args":["--yes"]}"#), None);
|
|
assert_eq!(
|
|
c(r#"{"cmd":"installRemote","target":"kay@box","command":"rm -rf ~"}"#),
|
|
None
|
|
);
|
|
assert_eq!(c(r#"{"cmd":"installRemote"}"#), None);
|
|
assert_eq!(c(r#"{"cmd":"localStatus","quick":"yes"}"#), None);
|
|
let long = format!(
|
|
r#"{{"cmd":"installRemote","target":"{}"}}"#,
|
|
"a".repeat(MAX_FIELD_LEN + 1)
|
|
);
|
|
assert_eq!(c(&long), None);
|
|
// Only the connect page, only in the main window, never a server page.
|
|
assert_eq!(
|
|
parse_ipc(page, r#"{"cmd":"copyInstallCommand"}"#, &Phase::Connect, false),
|
|
None
|
|
);
|
|
let loaded = Phase::Loaded(u("http://127.0.0.1:3000/"));
|
|
assert_eq!(
|
|
parse_ipc("http://127.0.0.1:3000/", r#"{"cmd":"installLocal"}"#, &loaded, true),
|
|
None
|
|
);
|
|
assert_eq!(parse_ipc(page, r#"{"cmd":"installLocal"}"#, &loaded, true), None);
|
|
}
|
|
|
|
#[test]
|
|
fn ipc_from_connect_page() {
|
|
let page = "codemanapp://localhost/index.html";
|
|
assert_eq!(
|
|
parse_ipc(page, r#"{"cmd":"ready"}"#, &Phase::Connect, true),
|
|
Some(Ipc::Connect(ConnectCmd::Ready))
|
|
);
|
|
assert_eq!(
|
|
parse_ipc(
|
|
"codemanapp://localhost/index.html?manual=1",
|
|
r#"{"cmd":"ready"}"#,
|
|
&Phase::Connect,
|
|
true
|
|
),
|
|
Some(Ipc::Connect(ConnectCmd::Ready))
|
|
);
|
|
assert_eq!(
|
|
parse_ipc(
|
|
page,
|
|
r#"{"cmd":"connect","url":"127.0.0.1:3000","username":"admin","password":"pw","remember":true}"#,
|
|
&Phase::Connect,
|
|
true
|
|
),
|
|
Some(Ipc::Connect(ConnectCmd::Connect {
|
|
url: "127.0.0.1:3000".into(),
|
|
username: Some("admin".into()),
|
|
password: Some("pw".into()),
|
|
remember: true
|
|
}))
|
|
);
|
|
assert_eq!(
|
|
parse_ipc(
|
|
page,
|
|
r#"{"cmd":"setStartFullscreen","value":true}"#,
|
|
&Phase::Connect,
|
|
true
|
|
),
|
|
Some(Ipc::Connect(ConnectCmd::SetStartFullscreen(true)))
|
|
);
|
|
// Not from a pop-out window, not once a server page committed, never
|
|
// host commands, and only from the connect page itself.
|
|
assert_eq!(parse_ipc(page, r#"{"cmd":"ready"}"#, &Phase::Connect, false), None);
|
|
let loaded = Phase::Loaded(u("http://127.0.0.1:3000/"));
|
|
assert_eq!(parse_ipc(page, r#"{"cmd":"ready"}"#, &loaded, true), None);
|
|
assert_eq!(
|
|
parse_ipc(page, r#"{"cmd":"openWindow","url":"x"}"#, &Phase::Connect, true),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
parse_ipc(page, r#"{"cmd":"sessionExpired"}"#, &Phase::Connect, true),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
parse_ipc(
|
|
"codemanapp://localhost/icon.png",
|
|
r#"{"cmd":"ready"}"#,
|
|
&Phase::Connect,
|
|
true
|
|
),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
parse_ipc(
|
|
"codemanapp://elsewhere/index.html",
|
|
r#"{"cmd":"ready"}"#,
|
|
&Phase::Connect,
|
|
true
|
|
),
|
|
None
|
|
);
|
|
let long = "p".repeat(2000);
|
|
assert_eq!(
|
|
parse_ipc(
|
|
page,
|
|
&format!(r#"{{"cmd":"connect","url":"h","password":"{long}"}}"#),
|
|
&Phase::Connect,
|
|
true
|
|
),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn external_urls_are_http_only() {
|
|
assert!(external_url("https://github.com/Ark0N/Codeman").is_some());
|
|
assert!(external_url("file:///Applications/Calculator.app").is_none());
|
|
assert!(external_url("codemanapp://localhost/").is_none());
|
|
assert!(external_url("vscode://file/x").is_none());
|
|
}
|
|
}
|