diff --git a/console/README.md b/console/README.md index f0530f56f..2fd102b8a 100644 --- a/console/README.md +++ b/console/README.md @@ -15,6 +15,23 @@ npm test home folder and a free localhost port, and closes its own server and browser. Node and npm are test tools only; the release build embeds its assets. +For Console CSS and JavaScript development, serve the assets from this checkout: + +```sh +THIRDSHIFT_CONSOLE_ASSETS=src/console/assets cargo run -- console --no-sign-in +``` + +In a debug build, `/assets/*` reads that directory with `no-store`; missing files +return 404. Open Console pages use their existing `/events` connection to swap +changed stylesheets in place after loading them, and reload for changed scripts, +usually within a second. No rebuild or manual reload is needed for asset edits. +Paths outside the directory and symlinks are refused. Custom Theme stylesheets +and fonts retain their usual handling. + +Without the variable, and in release builds even when it is set, assets come +from the binary and disk edits have no effect. Askama templates and Rust code +stay compiled in and still require a build. + Tests load Playwright through `tests/playwright.cjs`, which gives every page a 5 s default timeout (#1391): a broken locator fails fast instead of waiting 30 s. A step that needs longer passes its own `timeout`. diff --git a/console/tests/assets.test.cjs b/console/tests/assets.test.cjs new file mode 100644 index 000000000..32eda8309 --- /dev/null +++ b/console/tests/assets.test.cjs @@ -0,0 +1,104 @@ +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const { once } = require('node:events'); +const { mkdtemp, mkdir, cp, readFile, writeFile, rename, rm } = require('node:fs/promises'); +const { join, resolve } = require('node:path'); +const { tmpdir } = require('node:os'); +const { createInterface } = require('node:readline'); +const { chromium } = require('./playwright.cjs'); +const { test } = require('node:test'); + +test('development assets restyle open pages and reload changed scripts over one stream', async t => { + const home = await mkdtemp(join(tmpdir(), 'thirdshift-assets-')); + let server, exited, browser; + try { + const assets = join(home, 'assets'); + await cp(resolve(__dirname, '../../src/console/assets'), assets, { recursive: true }); + const cssPath = join(assets, 'console.css'); + const css = await readFile(cssPath, 'utf8'); + await writeFile(cssPath, `${css}\nbody { --asset-test: before; }\n`); + await mkdir(join(home, '.thirdshift')); + await writeFile(join(home, '.thirdshift/config.toml'), '[console]\nport = 0\n'); + server = spawn(process.env.THIRDSHIFT_BINARY ?? resolve(__dirname, '../../target/debug/thirdshift'), ['console', '--no-sign-in'], { + env: { ...process.env, HOME: home, THIRDSHIFT_CONSOLE_ASSETS: assets }, stdio: ['ignore', 'pipe', 'inherit'], + }); + exited = once(server, 'exit'); + const lines = createInterface({ input: server.stdout }); + const [origin] = await once(lines, 'line', { signal: AbortSignal.timeout(10000) }); + lines.close(); + if (!(await (await fetch(`${origin}/assets/console.css`)).text()).includes('--asset-test: before')) { + t.skip('binary does not enable debug development assets'); + return; + } + browser = await chromium.launch({ headless: true }); + const paths = ['/', '/logs', '/account', '/console-log']; + const pages = await Promise.all(paths.map(() => browser.newPage())); + const violations = [], streams = paths.map(() => 0), errors = []; + for (const [index, page] of pages.entries()) { + await page.exposeFunction('cspViolation', value => violations.push(value)); + page.on('pageerror', error => errors.push(error.message)); + await page.addInitScript(() => document.addEventListener('securitypolicyviolation', event => window.cspViolation(event.violatedDirective))); + page.on('request', request => { if (new URL(request.url()).pathname === '/events') streams[index]++; }); + const look = page.waitForResponse(response => response.url() === `${origin}/look`); + await page.goto(`${origin}${paths[index]}`); + await look; + await page.waitForFunction(() => getComputedStyle(document.body).getPropertyValue('--asset-test').trim() === 'before'); + await page.evaluate(() => { window.preservedThroughStyleChange = true; }); + } + const began = Date.now(); + // Atomic replacement is how editors commonly save a stylesheet. + await writeFile(`${cssPath}.tmp`, `${css}\nbody { --asset-test: changed; }\n`); + await rename(`${cssPath}.tmp`, cssPath); + for (const page of pages) { + await page.waitForFunction(() => getComputedStyle(document.body).getPropertyValue('--asset-test').trim() === 'changed', null, { timeout: 2000 }).catch(async error => { + t.diagnostic(JSON.stringify({ errors, streams })); + throw error; + }); + assert.equal(await page.evaluate(() => window.preservedThroughStyleChange), true); + } + assert.ok(Date.now() - began < 2000, 'stylesheet update should arrive in about a second'); + assert.deepEqual(streams, paths.map(() => 1)); + // Imported Theme styles are refreshed through their containing stylesheet. + const themesPath = join(assets, 'themes.css'); + await writeFile(themesPath, `${await readFile(themesPath, 'utf8')}\nbody { --import-test: changed; }\n`); + for (const page of pages) await page.waitForFunction(() => getComputedStyle(document.body).getPropertyValue('--import-test').trim() === 'changed'); + // Delay one real response across a newer edit. It must never win later. + const page = pages[0]; + let captured, release; + const held = new Promise(resolve => { captured = resolve; }); + const released = new Promise(resolve => { release = resolve; }); + let first = true; + await page.route('**/assets/console.css?revision=*', async route => { + if (!first) return route.continue(); + first = false; + const response = await route.fetch(); + captured(); + await released; + await route.fulfill({ response }); + }); + try { + await writeFile(cssPath, `${css}\nbody { --asset-test: stale; }\n`); + await Promise.race([held, new Promise((_, reject) => setTimeout(() => reject(new Error('stylesheet request missing')), 5000))]); + await writeFile(cssPath, `${css}\nbody { --asset-test: newest; }\n`); + await page.waitForFunction(() => getComputedStyle(document.body).getPropertyValue('--asset-test').trim() === 'newest'); + } finally { release(); } + await page.waitForFunction(() => !document.querySelector('link[media="not all"]')); + assert.equal(await page.evaluate(() => getComputedStyle(document.body).getPropertyValue('--asset-test').trim()), 'newest'); + await page.unroute('**/assets/console.css?revision=*'); + const scriptPath = join(assets, 'changes.js'); + const navigations = pages.map(page => page.waitForEvent('load')); + await writeFile(scriptPath, `${await readFile(scriptPath, 'utf8')}\nwindow.changedScriptRan = true;\n`); + await Promise.all(navigations); + for (const page of pages) { + await page.waitForFunction(() => window.changedScriptRan === true); + assert.equal(await page.evaluate(() => window.preservedThroughStyleChange), undefined); + } + assert.deepEqual(violations, []); + assert.deepEqual(errors, []); + } finally { + await browser?.close(); + if (server && server.exitCode === null) server.kill(); + if (exited) await exited; + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/src/console.rs b/src/console.rs index c3b1cb9dc..d69edc5ae 100644 --- a/src/console.rs +++ b/src/console.rs @@ -1,6 +1,7 @@ //! The machine's Console. Async execution is confined to this module. mod account_commands; mod accounts; +mod assets; mod auth; mod bug_origins; pub(crate) mod conversation; @@ -401,6 +402,7 @@ pub fn serve(no_sign_in: bool) -> Result<()> { accounts::Accounts::load()?; } let executable = restart::Executable::current()?; + let assets = assets::Development::from_environment()?; let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build()?; @@ -409,14 +411,16 @@ pub fn serve(no_sign_in: bool) -> Result<()> { .await .context("can't listen for the Console on localhost")?; let watched = executable.clone(); - let (changes, notifier, observer) = changes::observe(&config, &executable)?; + let (changes, notifier, observer) = changes::observe(&config, &executable, &assets)?; let github = github::follow(); let mut control = changes.clone(); println!("http://{}", listener.local_addr()?); std::io::stdout().flush()?; axum::serve( listener, - router(no_sign_in, changes).layer(axum::Extension(notifier)), + router(no_sign_in, changes) + .layer(axum::Extension(notifier)) + .layer(axum::Extension(assets)), ) .with_graceful_shutdown(async move { let mut accounts_revision = 0; @@ -744,15 +748,28 @@ fn page(result: std::result::Result, tokio::task::JoinError>) -> } } -async fn asset(Path(path): Path) -> Response { +async fn asset( + axum::Extension(assets): axum::Extension, + Path(path): Path, +) -> Response { if path == "user-themes.css" || path.starts_with("user-fonts/") { return match tokio::task::spawn_blocking(move || user_asset(&path)).await { Ok(response) => response, Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), }; } - let Some(file) = ASSETS.get_file(&path) else { - return StatusCode::NOT_FOUND.into_response(); + let body = if assets.directory().is_some() { + let requested = path.clone(); + match tokio::task::spawn_blocking(move || assets.read(&requested)).await { + Ok(Ok(bytes)) => axum::body::Body::from(bytes), + Ok(Err(_)) => return StatusCode::NOT_FOUND.into_response(), + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } + } else { + let Some(file) = ASSETS.get_file(&path) else { + return StatusCode::NOT_FOUND.into_response(); + }; + axum::body::Body::from(file.contents()) }; let content_type = match std::path::Path::new(&path) .extension() @@ -769,7 +786,7 @@ async fn asset(Path(path): Path) -> Response { (header::CONTENT_TYPE, content_type), (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), ], - file.contents(), + body, ) .into_response() } diff --git a/src/console/assets.rs b/src/console/assets.rs new file mode 100644 index 000000000..c270979f2 --- /dev/null +++ b/src/console/assets.rs @@ -0,0 +1,71 @@ +//! Debug-only asset selection and confined disk reads. +use std::ffi::CString; +use std::fs::{File, OpenOptions}; +use std::io::{self, Read}; +use std::os::fd::{AsRawFd, FromRawFd}; +use std::os::unix::fs::OpenOptionsExt; +use std::path::{Component, Path, PathBuf}; + +#[derive(Clone, Default)] +pub(super) struct Development { + directory: Option, +} + +impl Development { + pub fn from_environment() -> anyhow::Result { + #[cfg(debug_assertions)] + if let Some(directory) = std::env::var_os("THIRDSHIFT_CONSOLE_ASSETS") { + let directory = std::env::current_dir()?.join(directory); + return Ok(Self { + directory: Some(super::changes::observed_path(&directory)), + }); + } + Ok(Self::default()) + } + + pub fn directory(&self) -> Option<&Path> { + self.directory.as_deref() + } + + pub fn read(&self, path: &str) -> io::Result> { + // Check the raw spelling too: Path::components normalizes interior `.`. + if path.is_empty() + || path.contains('\\') + || path + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + { + return Err(io::ErrorKind::InvalidInput.into()); + } + let directory = self.directory.as_ref().ok_or(io::ErrorKind::NotFound)?; + let mut file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW) + .open(directory)?; + let mut parts = Path::new(path).components().peekable(); + while let Some(part) = parts.next() { + let Component::Normal(name) = part else { + return Err(io::ErrorKind::InvalidInput.into()); + }; + let name = CString::new(name.as_encoded_bytes())?; + let mut flags = libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC; + if parts.peek().is_some() { + flags |= libc::O_DIRECTORY; + } + // SAFETY: the directory fd and NUL-terminated component are live. + // Each open pins the next component and refuses symlink races. + let fd = unsafe { libc::openat(file.as_raw_fd(), name.as_ptr(), flags) }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: openat returned a new, uniquely owned descriptor. + file = unsafe { File::from_raw_fd(fd) }; + } + if !file.metadata()?.is_file() { + return Err(io::ErrorKind::InvalidInput.into()); + } + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + Ok(bytes) + } +} diff --git a/src/console/assets/changes.js b/src/console/assets/changes.js index 5039f31ef..398485f1a 100644 --- a/src/console/assets/changes.js +++ b/src/console/assets/changes.js @@ -1,6 +1,20 @@ // One stream per tab, shared by the page, peek and notification consumers. +import { refreshAssetStyles } from './look.js'; + const subscribers = new Set(); let polling, stream; +let assets; +// Pages without live Command content still need the shared stream in development. +document.addEventListener('console-development-assets', () => watchCommands(() => {}), { once: true }); +function refreshAssets(event) { + const current = JSON.parse(event.data); + if (assets && current.scripts !== assets.scripts) { + location.reload(); + } else if (current.styles !== (assets?.styles ?? 0)) { + refreshAssetStyles(); + } + assets = current; +} function refreshAll() { for (const refresh of subscribers) refresh(); } @@ -11,6 +25,7 @@ function startUpdates() { if (typeof EventSource === 'function') { stream = new EventSource('/events'); stream.addEventListener('commands', refreshAll); + stream.addEventListener('assets', refreshAssets); stream.addEventListener('error', startPolling); stream.addEventListener('open', () => { clearInterval(polling); diff --git a/src/console/assets/look.js b/src/console/assets/look.js index c06636777..297854111 100644 --- a/src/console/assets/look.js +++ b/src/console/assets/look.js @@ -12,36 +12,56 @@ let paused = false; let themeInventory = ''; let themeRevision = ''; let styleRequest = 0; -async function refreshThemeStyles() { - const requestedStyle = ++styleRequest; - const requestedRevision = revision; - const link = document.createElement('link'); - link.rel = 'stylesheet'; +async function refreshStylesheet(previous, link, isCurrent, preserveUnchanged = false) { + const id = link.id; + const media = link.media; + link.removeAttribute('id'); // Load without applying: a stale response must never replace current fonts. link.media = 'not all'; - link.href = `/assets/user-themes.css?revision=${Date.now()}`; + const url = new URL(link.href); + url.searchParams.set('revision', Date.now()); + link.href = url.href; const loaded = new Promise((resolve, reject) => { link.onload = resolve; link.onerror = reject; }); document.head.append(link); try { await loaded; - if (requestedStyle !== styleRequest || requestedRevision !== revision) { + if (!isCurrent() || (previous && !previous.isConnected)) { link.remove(); return; } - const previous = document.querySelector('#user-theme-styles'); const rules = sheet => [...sheet.cssRules].map(rule => rule.cssText).join('\n'); // Selecting a Theme changes /look's revision without changing its CSS. // Preserve the loaded font faces when the stylesheet is already current. - if (previous?.sheet && rules(previous.sheet) === rules(link.sheet)) { + if (preserveUnchanged && previous?.sheet && rules(previous.sheet) === rules(link.sheet)) { link.remove(); return; } - link.media = ''; + link.media = media; + link.id = id; previous?.remove(); - link.id = 'user-theme-styles'; } catch (error) { link.remove(); throw error; } } + +async function refreshThemeStyles() { + const requestedStyle = ++styleRequest; + const requestedRevision = revision; + const link = Object.assign(document.createElement('link'), { + rel: 'stylesheet', href: '/assets/user-themes.css', id: 'user-theme-styles', + }); + await refreshStylesheet(document.querySelector('#user-theme-styles'), link, + () => requestedStyle === styleRequest && requestedRevision === revision, true); +} + +let assetStyleRequest = 0; +export async function refreshAssetStyles() { + const requested = ++assetStyleRequest; + const links = [...document.querySelectorAll('link[rel="stylesheet"][href]')] + .filter(link => new URL(link.href).pathname.startsWith('/assets/') && link.id !== 'user-theme-styles' && link.media !== 'not all'); + // Reload the containing sheet too: console.css imports themes.css. + await Promise.all(links.map(link => refreshStylesheet(link, link.cloneNode(), () => requested === assetStyleRequest) + .catch(() => { /* Keep the loaded stylesheet if an edit cannot be loaded. */ }))); +} try { paused = localStorage.getItem('thirdshift:console:pause-motion') === 'true'; } catch { /* optional storage */ } export function motionAllowed() { return !paused && !reduced.matches; } @@ -77,11 +97,15 @@ function apply(settings) { } export async function loadLook() { - if (!themes || saving) return; + if (saving) return; const requestedRevision = revision; try { const response = await fetch('/look', { cache: 'no-store' }); if (!response.ok || response.redirected) throw new Error('Look unavailable'); + if (response.headers.get('x-thirdshift-console-assets') === 'development') { + document.dispatchEvent(new Event('console-development-assets')); + } + if (!themes) return; const settings = await response.json(); if (requestedRevision !== revision || saving) return; csrf = settings.csrf; @@ -106,7 +130,7 @@ export async function loadLook() { } } apply(settings); - } catch { message.textContent = 'Could not load the Console look. Reload to try again.'; } + } catch { if (message) message.textContent = 'Could not load the Console look. Reload to try again.'; } } async function save(path, values) { @@ -169,6 +193,6 @@ if (themes) { const options = [...document.querySelectorAll('#section-options input')]; save('/sections', { hide: options.filter(c => !c.checked).map(c => c.value).join(','), show: options.filter(c => c.checked).map(c => c.value).join(',') }); }); - loadLook(); } +loadLook(); activity(); diff --git a/src/console/changes.rs b/src/console/changes.rs index c0c12a89e..14a88837b 100644 --- a/src/console/changes.rs +++ b/src/console/changes.rs @@ -14,6 +14,8 @@ use tokio::sync::watch; pub(super) struct Revision { pub commands: u64, pub accounts: u64, + pub styles: u64, + pub scripts: u64, executable: u64, } @@ -68,6 +70,7 @@ struct Paths { root: PathBuf, logs: PathBuf, executable: PathBuf, + assets: Option, } impl Paths { @@ -76,6 +79,7 @@ impl Paths { root: observed_path(&root), logs: observed_path(&logs), executable: observed_path(&executable), + assets: None, } } @@ -90,6 +94,10 @@ impl Paths { revision.commands += 1; revision.accounts += 1; revision.executable += 1; + if self.assets.is_some() { + revision.styles += 1; + revision.scripts += 1; + } return; } }; @@ -106,6 +114,25 @@ impl Paths { { continue; } + if let Some(directory) = &self.assets + && (path.starts_with(directory) || directory.starts_with(&path)) + { + match path.extension().and_then(|ext| ext.to_str()) { + Some("js") => revision.scripts += 1, + Some("css" | "woff2" | "woff" | "otf" | "ttf") => revision.styles += 1, + _ if path == *directory + || path.is_dir() + || matches!( + event.kind, + EventKind::Remove(notify::event::RemoveKind::Folder) + ) => + { + revision.styles += 1; + revision.scripts += 1; + } + _ => {} + } + } if path == self.root.join("config.toml") || self.root.starts_with(&path) { revision.commands += 1; revision.accounts += 1; @@ -135,7 +162,13 @@ impl Paths { self.executable.parent().unwrap().to_path_buf(), RecursiveMode::NonRecursive, ), - ] { + ] + .into_iter() + .chain( + self.assets + .iter() + .map(|path| (path.clone(), RecursiveMode::Recursive)), + ) { // An absent log root is normal. Observe its nearest existing parent // until it is created, then move the recursive watch onto the root. let ancestor = path.ancestors().find(|path| path.is_dir()).unwrap(); @@ -179,12 +212,15 @@ pub(super) fn observed_path(path: &std::path::Path) -> PathBuf { pub(super) fn observe( config: &crate::config::UserConfig, executable: &super::restart::Executable, + assets: &super::assets::Development, ) -> Result<(Receiver, Notifier, tokio::task::JoinHandle<()>)> { - let paths = Arc::new(RwLock::new(Paths::new( + let mut paths = Paths::new( crate::config::home()?.join(".thirdshift"), config.logs_dir.clone(), executable.path.clone(), - ))); + ); + paths.assets = assets.directory().map(PathBuf::from); + let paths = Arc::new(RwLock::new(paths)); let (pending, mut notifications) = watch::channel(Revision::default()); let notifier = Notifier { pending: pending.clone(), @@ -193,9 +229,9 @@ pub(super) fn observe( let observed = paths.clone(); let enqueue = move |event| { pending.send_if_modified(|revision| { - let before = (revision.commands, revision.accounts, revision.executable); + let before = *revision; observed.read().unwrap().notice(event, revision); - before != (revision.commands, revision.accounts, revision.executable) + before != *revision }); }; let mut watcher = notify::recommended_watcher(enqueue.clone())?; @@ -247,6 +283,12 @@ pub(super) fn observe( if revision.executable != previous.executable { current.executable += 1; } + if revision.styles != previous.styles { + current.styles += 1; + } + if revision.scripts != previous.scripts { + current.scripts += 1; + } *current != before }); previous = revision; diff --git a/src/console/live.rs b/src/console/live.rs index 2815974b8..8a0ebb252 100644 --- a/src/console/live.rs +++ b/src/console/live.rs @@ -7,6 +7,7 @@ use axum::response::{ IntoResponse, Response, Sse, sse::{Event, KeepAlive}, }; +use futures_util::StreamExt; use super::subscription::{Readiness, Subscription}; @@ -14,22 +15,46 @@ pub(super) async fn events( State(changes): State, Extension(viewer): Extension, Extension(notifier): Extension, + Extension(assets): Extension, ) -> Response { // Every reconnect catches up even if its last notice was lost. let subscription = Subscription::new(changes, viewer, ¬ifier); - let stream = futures_util::stream::unfold(subscription, |mut subscription| async move { - match subscription.ready(Readiness::Wait).await { - Ok(true) => { - let event = Event::default().event("commands").data("changed"); - Some((Ok::<_, Infallible>(event), subscription)) + let development = assets.directory().is_some(); + let stream = futures_util::stream::unfold( + (subscription, None::), + move |(mut subscription, previous)| async move { + match subscription.ready(Readiness::Page).await { + Ok(true) => { + let current = subscription.revision(); + let mut events = Vec::new(); + if previous.is_none_or(|before| before.commands != current.commands) { + events.push(Ok::<_, Infallible>( + Event::default().event("commands").data("changed"), + )); + } + if development + && previous.is_none_or(|before| { + before.styles != current.styles || before.scripts != current.scripts + }) + { + events.push(Ok(Event::default().event("assets").data( + serde_json::json!({ + "styles": current.styles, "scripts": current.scripts, + }) + .to_string(), + ))); + } + Some((events, (subscription, Some(current)))) + } + Ok(false) => None, + Err(error) => { + eprintln!("thirdshift Console: can't authorize Inbox stream: {error:?}"); + None + } } - Ok(false) => None, - Err(error) => { - eprintln!("thirdshift Console: can't authorize Inbox stream: {error:?}"); - None - } - } - }); + }, + ) + .flat_map(futures_util::stream::iter); ( [("x-accel-buffering", "no"), ("cache-control", "no-store")], Sse::new(stream).keep_alive(KeepAlive::default()), diff --git a/src/console/look.rs b/src/console/look.rs index 85303d634..e7bc6d134 100644 --- a/src/console/look.rs +++ b/src/console/look.rs @@ -115,7 +115,10 @@ pub fn sections(hide: &[String], show: &[String], json_output: bool) -> Result) -> Response { +pub(super) async fn get( + Extension(viewer): Extension, + Extension(assets): Extension, +) -> Response { match tokio::task::spawn_blocking(|| { let config = UserConfig::load()?; let revision = super::accounts::digest(&export_theme(&config.console.theme)?); @@ -123,14 +126,23 @@ pub(super) async fn get(Extension(viewer): Extension) -> Response { }) .await { - Ok(Ok((config, names, revision))) => Json(json!({ - "theme": config.console.theme, - "theme_revision": revision, - "hidden_sections": config.console.hidden_sections, - "themes": names, "sections": console::SECTIONS, - "csrf": viewer.csrf, - })) - .into_response(), + Ok(Ok((config, names, revision))) => { + let mut response = Json(json!({ + "theme": config.console.theme, + "theme_revision": revision, + "hidden_sections": config.console.hidden_sections, + "themes": names, "sections": console::SECTIONS, + "csrf": viewer.csrf, + })) + .into_response(); + if assets.directory().is_some() { + response.headers_mut().insert( + "x-thirdshift-console-assets", + "development".parse().unwrap(), + ); + } + response + } error => failed(error), } } diff --git a/src/console/subscription.rs b/src/console/subscription.rs index e09dcddcf..dd1018409 100644 --- a/src/console/subscription.rs +++ b/src/console/subscription.rs @@ -6,13 +6,15 @@ use std::sync::Arc; use anyhow::Result; use super::auth::Viewer; -use super::changes::{Notifier, Receiver}; +use super::changes::{Notifier, Receiver, Revision}; use super::watching::Following; #[derive(Clone, Copy, PartialEq)] pub(super) enum Readiness { /// Catch up immediately once, then sleep until Commands change. Wait, + /// Page change notices include development assets as well as Commands. + Page, /// Permit work now, e.g. output already buffered behind the last batch. Drain, } @@ -21,7 +23,7 @@ pub(super) struct Subscription { changes: Receiver, check: Check, authorized_accounts: Option, - granted_commands: Option, + granted: Option, // Released on termination; None means no further work turn. lease: Option>, } @@ -37,7 +39,7 @@ impl Subscription { changes, check, authorized_accounts: None, - granted_commands: None, + granted: None, lease: Some(lease), } } @@ -55,6 +57,10 @@ impl Subscription { result } + pub fn revision(&self) -> Revision { + self.granted.expect("a work turn has been granted") + } + async fn next(&mut self, mode: Readiness) -> Result { loop { if self.changes.has_changed().is_err() { @@ -71,9 +77,16 @@ impl Subscription { self.authorized_accounts = Some(current.accounts); continue; } - if mode == Readiness::Drain || self.granted_commands != Some(current.commands) { + if mode == Readiness::Drain + || self.granted.is_none_or(|previous| { + previous.commands != current.commands + || (mode == Readiness::Page + && (previous.styles != current.styles + || previous.scripts != current.scripts)) + }) + { // A change during the caller's work stays unseen until it waits. - self.granted_commands = Some(current.commands); + self.granted = Some(current); return Ok(true); } if self.changes.changed().await.is_err() { diff --git a/tests/console.rs b/tests/console.rs index a4895d9db..9273f00d2 100644 --- a/tests/console.rs +++ b/tests/console.rs @@ -21,6 +21,9 @@ mod accounts; #[path = "console/look.rs"] mod look; +#[path = "console/assets.rs"] +mod assets; + #[path = "console/inbox.rs"] mod inbox; diff --git a/tests/console/assets.rs b/tests/console/assets.rs new file mode 100644 index 000000000..3f9eaad72 --- /dev/null +++ b/tests/console/assets.rs @@ -0,0 +1,138 @@ +//! Development assets through the real Console binary and HTTP routes. +use super::*; + +fn start(home: &Path, assets: Option<&Path>) -> Console { + let mut command = Command::new(env!("CARGO_BIN_EXE_thirdshift")); + command.args(["console", "--no-sign-in"]).env("HOME", home); + command.env_remove("THIRDSHIFT_CONSOLE_ASSETS"); + if let Some(assets) = assets { + command.env("THIRDSHIFT_CONSOLE_ASSETS", assets); + } + Console::start_command(command) +} + +#[test] +fn development_assets_are_debug_only_and_missing_files_do_not_fall_back() { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + let assets = tempfile::tempdir().unwrap(); + fs::write(assets.path().join("console.css"), "body { color: red; }").unwrap(); + let embedded = include_str!("../../src/console/assets/console.css"); + let normal = start(home.path(), None); + let csp = normal.get("/").headers()["content-security-policy"].clone(); + assert_eq!(normal.get("/assets/console.css").text().unwrap(), embedded); + drop(normal); + let console = start(home.path(), Some(assets.path())); + assert_eq!(console.get("/").headers()["content-security-policy"], csp); + let response = console.get("/assets/console.css"); + assert_eq!( + response.headers()["content-type"], + "text/css; charset=utf-8" + ); + if cfg!(debug_assertions) { + assert_eq!(response.headers()["cache-control"], "no-store"); + assert_eq!(response.text().unwrap(), "body { color: red; }"); + assert_eq!(console.get("/assets/console.js").status(), 404); + fs::write(assets.path().join("console.css"), "body { color: blue; }").unwrap(); + assert_eq!( + console.get("/assets/console.css").text().unwrap(), + "body { color: blue; }" + ); + } else { + assert_eq!(response.text().unwrap(), embedded); + fs::write(assets.path().join("console.css"), "body { color: blue; }").unwrap(); + assert_eq!(console.get("/assets/console.css").text().unwrap(), embedded); + assert_eq!(console.get("/assets/console.js").status(), 200); + } +} + +#[cfg(debug_assertions)] +#[test] +fn development_assets_refuse_traversal_absolute_paths_and_symlinks() { + use std::io::Write; + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + let assets = home.path().join("assets"); + fs::create_dir_all(assets.join("nested")).unwrap(); + fs::write(assets.join("nested/inside.css"), "safe").unwrap(); + fs::write(home.path().join("secret.css"), "private").unwrap(); + fs::create_dir(home.path().join("private")).unwrap(); + fs::write(home.path().join("private/secret.css"), "private").unwrap(); + std::os::unix::fs::symlink(home.path().join("secret.css"), assets.join("escape.css")).unwrap(); + std::os::unix::fs::symlink(home.path().join("private"), assets.join("outside")).unwrap(); + fs::write(assets.join("user-themes.css"), "forged theme").unwrap(); + let console = start(home.path(), Some(&assets)); + assert_eq!( + console.get("/assets/nested/inside.css").text().unwrap(), + "safe" + ); + assert!( + !console + .get("/assets/user-themes.css") + .text() + .unwrap() + .contains("forged") + ); + // Use HTTP directly so a URL client cannot normalize away the attack. + for path in [ + "/assets/../secret.css", + "/assets/%2e%2e/secret.css", + "/assets/%2Fetc%2Fpasswd", + "/assets//etc/passwd", + "/assets/C:%5Csecret.css", + "/assets/escape.css", + "/assets/outside/secret.css", + "/assets/nested", + ] { + let mut socket = + std::net::TcpStream::connect(console.address.trim_start_matches("http://")).unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + write!( + socket, + "GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" + ) + .unwrap(); + let mut status = String::new(); + BufReader::new(socket).read_line(&mut status).unwrap(); + assert!( + status.starts_with("HTTP/1.1 404") || status.starts_with("HTTP/1.1 400"), + "{path}: {status}" + ); + } +} + +#[test] +fn embedded_mode_does_not_send_asset_events_for_disk_edits() { + use std::io::Read; + for configured in [false, true] { + if configured && cfg!(debug_assertions) { + continue; + } + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + let assets = tempfile::tempdir().unwrap(); + fs::write(assets.path().join("console.css"), "before").unwrap(); + let console = start(home.path(), configured.then(|| assets.path())); + let embedded = include_str!("../../src/console/assets/console.css"); + assert_eq!(console.get("/assets/console.css").text().unwrap(), embedded); + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(1)) + .build() + .unwrap(); + let mut stream = client + .get(format!("{}/events", console.address)) + .send() + .unwrap(); + fs::write(assets.path().join("console.css"), "after").unwrap(); + assert_eq!(console.get("/assets/console.css").text().unwrap(), embedded); + let mut notices = String::new(); + assert!( + stream.read_to_string(&mut notices).is_err(), + "SSE must stay open" + ); + assert!(notices.contains("event: commands"), "{notices}"); + assert!(!notices.contains("event: assets"), "{notices}"); + } +}