diff --git a/rust/contracts/application-boundary.md b/rust/contracts/application-boundary.md index 2db7a5c..6508fb5 100644 --- a/rust/contracts/application-boundary.md +++ b/rust/contracts/application-boundary.md @@ -175,11 +175,26 @@ resources are released. A cancelled UI waiter cannot cancel another window's wai ### External HTML preview -Both products open an existing local `.html` / `.htm` document in the system's -configured web browser, independently of the file type's default editor. The -browser reads the saved file; this action does not save buffers or start a web -server. File URLs preserve Unicode and reserved characters. Browser discovery -and launch belong to platform adapters, and launch failures are shown to the user. +The desktop app opens an existing local `.html`, `.htm`, `.xhtml` or `.svg` +document in the system's configured web browser, independently of the file +type's default editor. The browser reads the file on disk, so the document's +unsaved changes are saved first; this action does not start a web server. File +URLs preserve Unicode and reserved characters. Browser discovery and launch +belong to platform adapters, and launch failures are shown to the user. + +### Embedded HTML preview + +Where the platform has a web engine, `.html`, `.htm` and `.xhtml` documents open +as editable text with their page beside it, in the editor, split and preview +modes of SVG documents. The page shows the editor's current text, unsaved edits +included, and loads its other files as a static site whose root and requests +`htmlPreview.site` and `htmlPreview.resolveRequest` decide. The page runs the +document's scripts, so the host serves only what those commands answer, never +writes through the site, keeps none of the page's storage once the preview +closes, and opens navigation away from the site with the link policy of +`markdown.resolveReference`. Where the page cannot be opened, HTML documents are +text alone and other previews are unaffected. The behavior fixture is +[`html-preview-v1.json`](../fixtures/editor/html-preview-v1.json). ## Document Lifecycle Contract diff --git a/rust/contracts/rust-core-api.md b/rust/contracts/rust-core-api.md index e5b4b7c..440c6aa 100644 --- a/rust/contracts/rust-core-api.md +++ b/rust/contracts/rust-core-api.md @@ -553,6 +553,8 @@ or removing a command fails until the table changes in the same commit. | `markdown.render` | Renders and sanitizes shared Markdown | | `markdown.outline` | Outlines the headings of shared Markdown as a document-symbol tree | | `markdown.resolveReference` | Decides what a link or an image of a rendered Markdown document points at | +| `htmlPreview.site` | Names the folder an HTML file is previewed from and the file's address in it | +| `htmlPreview.resolveRequest` | Decides what one request of an HTML preview serves | | `keymap.parseContext` | Compile a bounded boolean shortcut context (from `lithe-core-rules`) | | `keymap.evaluateContext` | Evaluate a precompiled shortcut context (from `lithe-core-rules`) | | `keymap.analyzeConflicts` | Find conflicts among the final layered shortcut bindings (from `lithe-core-rules`) | @@ -5596,6 +5598,61 @@ the preview's. Shared examples: `rust/fixtures/editor/markdown-outline-v1.json`. +## HTML file preview + +An HTML file is previewed as a static site, so a page loads its stylesheets, +scripts, images, fonts, media and neighbouring pages the way a static web server +would serve them. Both commands accept: + +```json +{ + "documentPath": "/workspace/site/index.html", + "workspaceRoot": "/workspace" +} +``` + +`workspaceRoot` is optional and sets the site's root exactly as it sets the +scope of `markdown.resolveReference`: the workspace for a document inside it, +otherwise the document's folder. `htmlPreview.site` returns +`{ "root": "/workspace", "address": "site/index.html", "mime": "text/html; charset=utf-8" }`; +`address` is the document's path from the root with each segment +percent-encoded, which the frontend appends to the address it serves the site +from, and `mime` is the content type the document is served as, by its +extension like the site's other files. A `documentPath` that is not absolute is +`invalid_request`. + +`htmlPreview.resolveRequest` adds `"path"`, the requested path from the root as +the web engine reports it: percent-decoded, `/`-separated, without query or +fragment, and empty for the root. Core does not decode it again. The response is +tagged by `kind`: + +| kind | fields | the frontend serves | +| --- | --- | --- | +| `document` | — | The editor's text of the document as the site's `mime`, so unsaved edits show | +| `file` | `path`, `mime` | The canonical file `path`, read without following another link, as `mime` | +| `directory` | — | A page that sends the browser on to the folder's `index.html` | +| `denied` | — | HTTP 403 | +| `none` | — | HTTP 404 | + +The path resolves lexically from the root and must stay inside it before and +after Core resolves symbolic links; a segment holding `\`, `:` or NUL is +`none`. A path that reaches the document, under any name, is `document`. A +hidden name (one starting with `.`) is `denied`, except the leading folders the +document's own folder shares, so a page inside `.github/pages` can load its +neighbours but not `.git/config`. A file with a key or certificate extension +(`jks`, `key`, `keystore`, `p12`, `pem`, `pfx`) is `denied`. Otherwise an +existing regular file is `file`, a folder whose `index.html` is a regular file +inside the root is `directory`, and anything else is `none`. `mime` comes from +the file's extension, with text types in UTF-8 and +`application/octet-stream` for an unknown one. + +The page runs the document's scripts, so frontends trust nothing it requests: +they serve only what this command answers, never write through the site, and +handle top-level navigation away from it with the link policy of +`markdown.resolveReference`. + +Shared examples: `rust/fixtures/editor/html-preview-v1.json`. + ### Structured logging of recoverable LSP maintenance warnings `lsp.pollEvents` log observations may carry the existing `error` object even at diff --git a/rust/crates/core/src/protocol/command.rs b/rust/crates/core/src/protocol/command.rs index ac897bd..3774c2b 100644 --- a/rust/crates/core/src/protocol/command.rs +++ b/rust/crates/core/src/protocol/command.rs @@ -131,6 +131,11 @@ pub enum CoreCommand { /// Decides what a link or an image of a rendered Markdown document points at /// (`markdown.resolveReference`). MarkdownResolveReference, + /// Finds the site an HTML file is previewed as and the document's address in it + /// (`htmlPreview.site`). + HtmlPreviewSite, + /// Decides what one request of an HTML preview's site serves (`htmlPreview.resolveRequest`). + HtmlPreviewResolveRequest, /// Editor reducers: workbench state, text edits, find, navigation and preferences (`editor.*`). Editor(EditorCommand), /// Applies one transition to the rearrangeable workbench chrome: stripe, title bar and status bar @@ -1191,6 +1196,8 @@ impl CoreCommand { "markdown.render" => Some(Self::MarkdownRender), "markdown.outline" => Some(Self::MarkdownOutline), "markdown.resolveReference" => Some(Self::MarkdownResolveReference), + "htmlPreview.site" => Some(Self::HtmlPreviewSite), + "htmlPreview.resolveRequest" => Some(Self::HtmlPreviewResolveRequest), "keymap.parseContext" => Some(Self::Keymap(KeymapCommand::ParseContext)), "keymap.evaluateContext" => Some(Self::Keymap(KeymapCommand::EvaluateContext)), "keymap.analyzeConflicts" => Some(Self::Keymap(KeymapCommand::AnalyzeConflicts)), diff --git a/rust/crates/core/src/runtime/dispatcher.rs b/rust/crates/core/src/runtime/dispatcher.rs index 7480c1b..971d6de 100644 --- a/rust/crates/core/src/runtime/dispatcher.rs +++ b/rust/crates/core/src/runtime/dispatcher.rs @@ -4,8 +4,9 @@ //! child module named after it; the remaining small families dispatch here. use crate::project::{ - self, DocumentLifecycleRequest, FileReadRequest, FileWriteRequest, MarkdownOutlineRequest, - MarkdownReferenceRequest, MarkdownRenderRequest, + self, DocumentLifecycleRequest, FileReadRequest, FileWriteRequest, HtmlPreviewRequest, + HtmlPreviewSiteRequest, MarkdownOutlineRequest, MarkdownReferenceRequest, + MarkdownRenderRequest, }; use crate::protocol::{CoreCommand, CoreError, CoreRequest, CoreResponse, ErrorCode, GitCommand}; use lithe_github::{NormalizeResponseRequest, ParseRemoteRequest, RequestPlanRequest}; @@ -492,6 +493,44 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::HtmlPreviewSite => { + let site = serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid HTML preview site request", + ) + .with_details(error.to_string()) + }) + .and_then(|request| { + project::html_preview_site(request).ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "HTML preview document must be an absolute file path", + ) + }) + }); + match site { + Ok(site) => CoreResponse::success( + id, + serde_json::to_value(site).expect("HTML preview site should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::HtmlPreviewResolveRequest => { + match serde_json::from_value::(parsed.payload).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid HTML preview request") + .with_details(error.to_string()) + }) { + Ok(request) => CoreResponse::success( + id, + serde_json::to_value(project::resolve_html_preview_request(request)) + .expect("HTML preview resource should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::Lsp(command) => lsp::dispatch(command, id, parsed.payload), CoreCommand::NetworkProxyPolicy => { match serde_json::from_value::(parsed.payload) diff --git a/rust/crates/core/src/runtime/request_trace.rs b/rust/crates/core/src/runtime/request_trace.rs index 3fec126..af7440f 100644 --- a/rust/crates/core/src/runtime/request_trace.rs +++ b/rust/crates/core/src/runtime/request_trace.rs @@ -156,6 +156,7 @@ pub(crate) fn is_hot(method: &str) -> bool { | "markdown.render" | "markdown.outline" | "markdown.resolveReference" + | "htmlPreview.resolveRequest" | "workspace.classifyVisibility" | "workspace.visibilityDefaults" | "lsp.syncDocument" diff --git a/rust/crates/core/src/tests/project.rs b/rust/crates/core/src/tests/project.rs index 591429d..c3785dd 100644 --- a/rust/crates/core/src/tests/project.rs +++ b/rust/crates/core/src/tests/project.rs @@ -998,6 +998,100 @@ fn markdown_resolve_reference_command_answers_links_and_resources() { ); } +#[test] +fn html_preview_commands_match_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/editor/html-preview-v1.json" + )) + .expect("HTML preview fixture should decode"); + let top = temporary_root("html-preview"); + let _ = fs::remove_dir_all(&top); + struct Cleanup(std::path::PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(top.clone()); + let root = top.join("workspace"); + let under = |relative: &str| { + relative + .split('/') + .filter(|name| !name.is_empty()) + .fold(root.clone(), |path, name| path.join(name)) + }; + for entry in fixture["files"].as_array().expect("fixture files") { + let entry = entry.as_str().expect("file entry"); + let path = under(entry); + if entry.ends_with('/') { + fs::create_dir_all(&path).expect("fixture folder should be creatable"); + } else { + fs::create_dir_all(path.parent().expect("file folder")) + .expect("fixture folder should be creatable"); + fs::write(&path, "x").expect("fixture file should write"); + } + } + let call = |command: &str, payload: Value| -> Value { + let request = serde_json::json!({ "id": "html", "command": command, "payload": payload }); + serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("HTML preview request should encode"), + )) + .expect("HTML preview response should be JSON") + }; + + for case in fixture["cases"].as_array().expect("fixture cases") { + let name = case["name"].as_str().expect("case name"); + let mut payload = serde_json::json!({ + "documentPath": under(case["document"].as_str().expect("case document")).to_string_lossy() + }); + if case["inWorkspace"] == true { + payload["workspaceRoot"] = Value::from(root.to_string_lossy().into_owned()); + } + let site = call("htmlPreview.site", payload.clone()); + assert_eq!(site["ok"], true, "{name}: {site}"); + let want = &case["site"]; + assert_eq!( + site["data"]["root"], + under(want["root"].as_str().expect("site root")) + .to_string_lossy() + .as_ref(), + "{name}" + ); + assert_eq!(site["data"]["address"], want["address"], "{name}"); + assert_eq!(site["data"]["mime"], want["mime"], "{name}"); + + for request in case["requests"].as_array().expect("case requests") { + let path = request["path"].as_str().expect("request path"); + payload["path"] = Value::from(path); + let response = call("htmlPreview.resolveRequest", payload.clone()); + assert_eq!(response["ok"], true, "{name}: {path}: {response}"); + let mut want = serde_json::json!({ "kind": request["kind"] }); + if let Some(file) = request["file"].as_str() { + want["path"] = Value::from( + fs::canonicalize(under(file)) + .expect("canonical fixture file") + .to_string_lossy() + .into_owned(), + ); + want["mime"] = request["mime"].clone(); + } + assert_eq!(response["data"], want, "{name}: {path}"); + } + } + + for document in fixture["invalidDocuments"] + .as_array() + .expect("invalid documents") + { + let response = call( + "htmlPreview.site", + serde_json::json!({ "documentPath": document }), + ); + assert_eq!(response["ok"], false, "{document}: {response}"); + assert_eq!(response["error"]["code"], "invalid_request", "{document}"); + } +} + #[test] fn search_matches_shared_fixture_semantics() { let fixture = fixture(); diff --git a/rust/crates/project/src/html_preview.rs b/rust/crates/project/src/html_preview.rs new file mode 100644 index 0000000..d8d9f34 --- /dev/null +++ b/rust/crates/project/src/html_preview.rs @@ -0,0 +1,531 @@ +//! The static site an HTML file is previewed as. +//! +//! A preview loads an HTML document with everything it references, the way a static web server +//! would: stylesheets, scripts, images, fonts, media and the pages beside it. This module decides +//! what the site is and what each request of it serves, so every frontend serves the same files. +//! The site's root is the folder the document may reach, as for a Markdown document; the document +//! itself is the editor's text, so unsaved changes show; any other file must be an existing +//! regular file inside the root that is neither hidden nor a key or certificate store. The page +//! runs the document's own scripts, so nothing it requests is trusted: the frontend reads only +//! what this answers. + +use crate::markdown_reference::{document_scope, join_lexically, within}; +use serde::{Deserialize, Serialize}; +use std::path::{Component, Path}; + +/// The file a folder of the site serves. +const INDEX: &str = "index.html"; + +/// Lowercase extensions of files that hold keys or certificates, refused even inside the root. +const SECRET_EXTENSIONS: &[&str] = &["jks", "key", "keystore", "p12", "pem", "pfx"]; + +/// Content types by lowercase extension. Engines refuse a module script or a stylesheet of the +/// wrong type, so the types a page depends on are named here rather than sniffed. Text types are +/// UTF-8, the encoding of the editor's documents and of web files in practice; a file in another +/// encoding is read right only with a byte order mark, which outranks the header. +const CONTENT_TYPES: &[(&str, &str)] = &[ + ("apng", "image/apng"), + ("avif", "image/avif"), + ("bmp", "image/bmp"), + ("cjs", "text/javascript; charset=utf-8"), + ("css", "text/css; charset=utf-8"), + ("csv", "text/csv; charset=utf-8"), + ("flac", "audio/flac"), + ("gif", "image/gif"), + ("htm", "text/html; charset=utf-8"), + ("html", "text/html; charset=utf-8"), + ("ico", "image/x-icon"), + ("jpeg", "image/jpeg"), + ("jpg", "image/jpeg"), + ("js", "text/javascript; charset=utf-8"), + ("json", "application/json"), + ("m4a", "audio/mp4"), + ("map", "application/json"), + ("md", "text/markdown; charset=utf-8"), + ("mjs", "text/javascript; charset=utf-8"), + ("mp3", "audio/mpeg"), + ("mp4", "video/mp4"), + ("oga", "audio/ogg"), + ("ogg", "audio/ogg"), + ("ogv", "video/ogg"), + ("otf", "font/otf"), + ("pdf", "application/pdf"), + ("png", "image/png"), + ("svg", "image/svg+xml"), + ("ttf", "font/ttf"), + ("txt", "text/plain; charset=utf-8"), + ("vtt", "text/vtt; charset=utf-8"), + ("wasm", "application/wasm"), + ("wav", "audio/wav"), + ("webm", "video/webm"), + ("webmanifest", "application/manifest+json"), + ("webp", "image/webp"), + ("woff", "font/woff"), + ("woff2", "font/woff2"), + ("xhtml", "application/xhtml+xml; charset=utf-8"), + ("xml", "application/xml"), +]; + +/// The type of a file whose extension is not in [`CONTENT_TYPES`]. +const UNKNOWN_TYPE: &str = "application/octet-stream"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// The HTML document a preview shows. +pub struct HtmlPreviewSiteRequest { + /// Absolute path of the document. + pub document_path: String, + /// Absolute path of the open workspace's root. A document inside it has the whole workspace + /// as its site; without one, or for a document outside it, the site is the document's folder. + #[serde(default)] + pub workspace_root: Option, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +/// Where a document's site is, and where the document is in it. +pub struct HtmlPreviewSite { + /// Absolute path of the site's root folder. + pub root: String, + /// The document's path from the root as a relative URL: its segments percent-encoded and + /// joined by `/`. The frontend appends it to the address its site is served from. + pub address: String, + /// Content type to serve the document as, by its extension, as the site's other files are. + pub mime: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// One request of a document's site. +pub struct HtmlPreviewRequest { + /// Absolute path of the document the site is for. + pub document_path: String, + /// The workspace's root, as in [`HtmlPreviewSiteRequest`]. + #[serde(default)] + pub workspace_root: Option, + /// The requested path from the site's root as the web engine reports it: already + /// percent-decoded, separated by `/`, without a query or a fragment, and empty for the root. + /// It is not decoded again, so a file named `a%20b.html` is reachable. + pub path: String, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "camelCase")] +/// What one request of a site serves. +pub enum HtmlPreviewResource { + /// The previewed document, which the frontend serves from the editor's text as UTF-8 HTML. + Document, + /// A file of the site. The path is canonical: an existing regular file inside the root, which + /// the frontend reads without following another link, and serves as `mime`. + File { + /// Absolute, canonical path of the file. + path: String, + /// Content type to serve the file as. + mime: String, + }, + /// A folder of the site that holds an `index.html`; the frontend sends the page on to that + /// file, relative to the folder. + Directory, + /// A hidden path or a key or certificate store inside the root, refused: HTTP 403. + Denied, + /// Nothing is served: a path out of the root, a malformed path, or a file or index that does + /// not exist. HTTP 404. + None, +} + +/// The site of a document, or `None` for a document that is not an absolute file path. +pub fn html_preview_site(request: HtmlPreviewSiteRequest) -> Option { + let document = Path::new(&request.document_path); + let (_, root) = document_scope(document, request.workspace_root.as_deref())?; + let relative = names(document.strip_prefix(root).ok()?)?; + if relative.is_empty() { + return None; + } + let address = relative + .iter() + .map(|name| percent_encode(name)) + .collect::>() + .join("/"); + Some(HtmlPreviewSite { + root: root.to_string_lossy().into_owned(), + address, + mime: content_type(document).to_string(), + }) +} + +/// Resolves one request of a document's site. +/// +/// The request path is checked as written before the file system is touched, and again once +/// symbolic links are resolved, so neither `..` nor a link inside the root reaches a file outside +/// it or a hidden one. +pub fn resolve_html_preview_request(request: HtmlPreviewRequest) -> HtmlPreviewResource { + let document = Path::new(&request.document_path); + let Some((folder, root)) = document_scope(document, request.workspace_root.as_deref()) else { + return HtmlPreviewResource::None; + }; + // A segment the engine would not send as one name, such as a Windows drive or a backslash + // path, makes no file of the site. + if request + .path + .split('/') + .any(|segment| segment.contains(['\\', ':', '\0'])) + { + return HtmlPreviewResource::None; + } + let lexical = join_lexically(root, &request.path); + if !within(&lexical, root) { + return HtmlPreviewResource::None; + } + if lexical == document { + return HtmlPreviewResource::Document; + } + // Hidden folders the document itself lies in are its own; any other is not served. + let home = folder.strip_prefix(root).unwrap_or(Path::new("")); + if refused(lexical.strip_prefix(root).unwrap_or(&lexical), home) { + return HtmlPreviewResource::Denied; + } + let (Ok(canonical_root), Ok(path)) = + (std::fs::canonicalize(root), std::fs::canonicalize(&lexical)) + else { + return HtmlPreviewResource::None; + }; + let Ok(inside) = path.strip_prefix(&canonical_root) else { + return HtmlPreviewResource::None; + }; + let canonical_home = std::fs::canonicalize(folder) + .ok() + .and_then(|folder| { + folder + .strip_prefix(&canonical_root) + .ok() + .map(Path::to_path_buf) + }) + .unwrap_or_default(); + if refused(inside, &canonical_home) { + return HtmlPreviewResource::Denied; + } + if path.is_dir() { + return match resolve_index(&path, &canonical_root) { + true => HtmlPreviewResource::Directory, + false => HtmlPreviewResource::None, + }; + } + if !path.is_file() { + return HtmlPreviewResource::None; + } + // The document reached through another name, such as a link to it, is still the editor's. + if std::fs::canonicalize(document).is_ok_and(|document| document == path) { + return HtmlPreviewResource::Document; + } + HtmlPreviewResource::File { + mime: content_type(&path).to_string(), + path: path.to_string_lossy().into_owned(), + } +} + +/// Whether a folder of the site has an index file that stays inside the root. +fn resolve_index(folder: &Path, root: &Path) -> bool { + std::fs::canonicalize(folder.join(INDEX)) + .is_ok_and(|index| index.starts_with(root) && index.is_file()) +} + +/// Whether a path inside the root, given from the root, is refused: it names a hidden file or +/// folder below `home`, the document's folder from the root, or a key or certificate store. +fn refused(relative: &Path, home: &Path) -> bool { + let mut home = home.components(); + let mut own = true; + let mut last = None; + for component in relative.components() { + let Component::Normal(name) = component else { + continue; + }; + last = Some(name); + // The leading folders shared with the document's own folder were chosen by the user. + own = own && home.next() == Some(component); + if !own && name.to_string_lossy().starts_with('.') { + return true; + } + } + last.and_then(|name| Path::new(name).extension()) + .map(|extension| extension.to_string_lossy().to_ascii_lowercase()) + .is_some_and(|extension| SECRET_EXTENSIONS.contains(&extension.as_str())) +} + +/// The content type a file is served as, by its extension. +fn content_type(path: &Path) -> &'static str { + let extension = path + .extension() + .map(|extension| extension.to_string_lossy().to_ascii_lowercase()) + .unwrap_or_default(); + CONTENT_TYPES + .iter() + .find(|(name, _)| *name == extension) + .map_or(UNKNOWN_TYPE, |(_, mime)| mime) +} + +/// The names of a relative path, or `None` when it has any other component. +fn names(relative: &Path) -> Option> { + relative + .components() + .map(|component| match component { + Component::Normal(name) => name.to_str().map(str::to_string), + _ => None, + }) + .collect() +} + +/// Percent-encodes one path segment, keeping only RFC 3986's unreserved characters, so that no +/// name reads as a query, a fragment or another segment. +fn percent_encode(segment: &str) -> String { + let mut encoded = String::with_capacity(segment.len()); + for byte in segment.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(byte as char); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + +#[cfg(test)] +mod tests { + use super::{ + html_preview_site, resolve_html_preview_request, HtmlPreviewRequest, HtmlPreviewResource, + HtmlPreviewSite, HtmlPreviewSiteRequest, + }; + use std::fs; + use std::path::{Path, PathBuf}; + + /// The path of slash-separated `relative` under `base`, in the platform's own separators. + fn under(base: &Path, relative: &str) -> PathBuf { + relative + .split('/') + .fold(base.to_path_buf(), |path, name| path.join(name)) + } + + /// A temporary workspace with a site in `site/`, a page in a hidden folder, secrets and, + /// outside the workspace, `outside.css`. + struct Workspace { + top: PathBuf, + root: PathBuf, + } + + impl Workspace { + fn new(name: &str) -> Self { + let top = crate::test_support::temporary_root(&format!("html-preview-{name}")); + let root = top.join("workspace"); + for folder in [ + "site/css", + "site/docs", + "site/empty", + ".github/pages", + ".git", + ] { + fs::create_dir_all(under(&root, folder)).expect("workspace folder"); + } + for file in [ + "site/index.html", + "site/page one.html", + "site/a%20b.html", + "site/css/app.css", + "site/app.mjs", + "site/data.bin", + "site/docs/index.html", + "site/server.pem", + "site/.env", + ".git/config", + ".github/pages/page.html", + ".github/pages/style.css", + ".github/secret.txt", + ] { + fs::write(under(&root, file), b"x").expect("workspace file"); + } + fs::write(top.join("outside.css"), b"x").expect("outside file"); + Self { top, root } + } + + fn document(&self, relative: &str) -> String { + under(&self.root, relative).to_string_lossy().into_owned() + } + + fn resolve_for(&self, document: &str, path: &str) -> HtmlPreviewResource { + resolve_html_preview_request(HtmlPreviewRequest { + document_path: self.document(document), + workspace_root: Some(self.root.to_string_lossy().into_owned()), + path: path.to_string(), + }) + } + + fn resolve(&self, path: &str) -> HtmlPreviewResource { + self.resolve_for("site/index.html", path) + } + + fn file(&self, relative: &str, mime: &str) -> HtmlPreviewResource { + HtmlPreviewResource::File { + path: fs::canonicalize(under(&self.root, relative)) + .expect("canonical workspace file") + .to_string_lossy() + .into_owned(), + mime: mime.to_string(), + } + } + } + + impl Drop for Workspace { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.top); + } + } + + #[test] + fn the_site_is_the_workspace_with_the_document_addressed_from_its_root() { + let workspace = Workspace::new("site"); + let site = html_preview_site(HtmlPreviewSiteRequest { + document_path: workspace.document("site/page one.html"), + workspace_root: Some(workspace.root.to_string_lossy().into_owned()), + }); + assert_eq!( + site, + Some(HtmlPreviewSite { + root: workspace.root.to_string_lossy().into_owned(), + address: "site/page%20one.html".to_string(), + mime: "text/html; charset=utf-8".to_string(), + }) + ); + // Without a workspace, the document's folder is the site. + let alone = html_preview_site(HtmlPreviewSiteRequest { + document_path: workspace.document("site/a%20b.html"), + workspace_root: None, + }); + assert_eq!( + alone.map(|site| site.address), + Some("a%2520b.html".to_string()) + ); + assert_eq!( + html_preview_site(HtmlPreviewSiteRequest { + document_path: "page.html".to_string(), + workspace_root: None, + }), + None + ); + } + + #[test] + fn requests_serve_the_document_files_and_folders_of_the_site() { + let workspace = Workspace::new("files"); + assert_eq!( + workspace.resolve("site/index.html"), + HtmlPreviewResource::Document + ); + assert_eq!( + workspace.resolve("site/css/app.css"), + workspace.file("site/css/app.css", "text/css; charset=utf-8") + ); + assert_eq!( + workspace.resolve("site/app.mjs"), + workspace.file("site/app.mjs", "text/javascript; charset=utf-8") + ); + assert_eq!( + workspace.resolve("site/data.bin"), + workspace.file("site/data.bin", "application/octet-stream") + ); + // The engine has decoded the path already; it is not decoded again. + assert_eq!( + workspace.resolve("site/page one.html"), + workspace.file("site/page one.html", "text/html; charset=utf-8") + ); + assert_eq!( + workspace.resolve("site/a%20b.html"), + workspace.file("site/a%20b.html", "text/html; charset=utf-8") + ); + assert_eq!( + workspace.resolve("site/docs"), + HtmlPreviewResource::Directory + ); + assert_eq!( + workspace.resolve("site/docs/"), + HtmlPreviewResource::Directory + ); + assert_eq!(workspace.resolve("site/empty"), HtmlPreviewResource::None); + assert_eq!( + workspace.resolve("site/missing.css"), + HtmlPreviewResource::None + ); + } + + #[test] + fn requests_stay_inside_the_root_and_out_of_hidden_files_and_keys() { + let workspace = Workspace::new("refused"); + for path in [ + "../outside.css", + "site/../../outside.css", + "C:/Windows/win.ini", + "site\\..\\..\\outside.css", + "site/css/app.css\0", + ] { + assert_eq!(workspace.resolve(path), HtmlPreviewResource::None, "{path}"); + } + for path in [ + ".git/config", + "site/.env", + "site/server.pem", + ".github/pages/page.html", + ] { + assert_eq!( + workspace.resolve(path), + HtmlPreviewResource::Denied, + "{path}" + ); + } + // A page inside a hidden folder reaches its own folder, but no other hidden one. + let page = ".github/pages/page.html"; + assert_eq!( + workspace.resolve_for(page, ".github/pages/style.css"), + workspace.file(".github/pages/style.css", "text/css; charset=utf-8") + ); + assert_eq!( + workspace.resolve_for(page, ".github/secret.txt"), + workspace.file(".github/secret.txt", "text/plain; charset=utf-8") + ); + assert_eq!( + workspace.resolve_for(page, ".git/config"), + HtmlPreviewResource::Denied + ); + assert_eq!( + workspace.resolve_for(page, page), + HtmlPreviewResource::Document + ); + } + + #[cfg(unix)] + #[test] + fn links_are_resolved_before_the_root_and_hidden_names_are_checked() { + use std::os::unix::fs::symlink; + let workspace = Workspace::new("links"); + symlink( + workspace.top.join("outside.css"), + under(&workspace.root, "site/out.css"), + ) + .expect("link out of the workspace"); + symlink( + under(&workspace.root, "site/.env"), + under(&workspace.root, "site/env.txt"), + ) + .expect("link to a hidden file"); + symlink( + under(&workspace.root, "site/index.html"), + under(&workspace.root, "site/home.html"), + ) + .expect("link to the document"); + assert_eq!(workspace.resolve("site/out.css"), HtmlPreviewResource::None); + assert_eq!( + workspace.resolve("site/env.txt"), + HtmlPreviewResource::Denied + ); + assert_eq!( + workspace.resolve("site/home.html"), + HtmlPreviewResource::Document + ); + } +} diff --git a/rust/crates/project/src/lib.rs b/rust/crates/project/src/lib.rs index ab21858..ae3f31f 100644 --- a/rust/crates/project/src/lib.rs +++ b/rust/crates/project/src/lib.rs @@ -1,5 +1,6 @@ //! Project services: workspace files and visibility, indexing policy, search, local history, -//! document persistence decisions, and Markdown rendering and heading outlines. +//! document persistence decisions, Markdown rendering and heading outlines, and the sites of +//! HTML previews. //! //! Language-server sessions sit above this crate. Calls that change indexing or the search index //! take an `update_providers` callback, which lithe-core uses to tell running sessions which @@ -14,6 +15,7 @@ pub mod file_name_order; pub mod file_operations; pub mod files; mod history; +mod html_preview; pub mod indexing; mod markdown; mod markdown_outline; @@ -27,6 +29,7 @@ mod search_paging; pub use document_lifecycle::*; pub use files::*; pub use history::*; +pub use html_preview::*; pub use markdown::*; pub use markdown_outline::*; pub use markdown_reference::*; diff --git a/rust/crates/project/src/markdown_reference.rs b/rust/crates/project/src/markdown_reference.rs index 541be5f..a4cc3ef 100644 --- a/rust/crates/project/src/markdown_reference.rs +++ b/rust/crates/project/src/markdown_reference.rs @@ -126,15 +126,9 @@ pub fn resolve_reference(request: MarkdownReferenceRequest) -> MarkdownReference return MarkdownReference::None; } let document = Path::new(&request.document_path); - let Some(folder) = document.parent().filter(|_| document.is_absolute()) else { + let Some((folder, scope)) = document_scope(document, request.workspace_root.as_deref()) else { return MarkdownReference::None; }; - let scope = request - .workspace_root - .as_deref() - .map(Path::new) - .filter(|root| root.is_absolute() && within(document, root)) - .unwrap_or(folder); // `/path` is the workspace's, as on a repository host; any other path is the document's. let (base, relative) = match decoded.strip_prefix('/') { Some(rest) => (scope, rest), @@ -150,6 +144,21 @@ pub fn resolve_reference(request: MarkdownReferenceRequest) -> MarkdownReference } } +/// The folder of an absolute document and the scope its references reach: the workspace's root +/// for a document inside it, otherwise the document's own folder. A relative document has +/// neither. +pub(crate) fn document_scope<'a>( + document: &'a Path, + workspace_root: Option<&'a str>, +) -> Option<(&'a Path, &'a Path)> { + let folder = document.parent().filter(|_| document.is_absolute())?; + let scope = workspace_root + .map(Path::new) + .filter(|root| root.is_absolute() && within(document, root)) + .unwrap_or(folder); + Some((folder, scope)) +} + /// The answer for an address with a scheme: links follow web and mail addresses, resources /// download web addresses that name a host. fn web_address(purpose: MarkdownReferencePurpose, address: &str) -> MarkdownReference { @@ -241,7 +250,7 @@ fn percent_decode(text: &str) -> Option { /// Joins slash-separated segments to a base, resolving `.` and `..` without the file system. A /// `..` at the root of the file system stays there. -fn join_lexically(base: &Path, relative: &str) -> PathBuf { +pub(crate) fn join_lexically(base: &Path, relative: &str) -> PathBuf { let mut path = base.to_path_buf(); for segment in relative.split('/') { match segment { @@ -257,7 +266,7 @@ fn join_lexically(base: &Path, relative: &str) -> PathBuf { /// Whether a path lies inside a folder, comparing their normal components. Neither needs to /// exist; `..` components do not count as leaving and are resolved by the callers first. -fn within(path: &Path, folder: &Path) -> bool { +pub(crate) fn within(path: &Path, folder: &Path) -> bool { fn normal(path: &Path) -> Vec> { path.components() .filter(|component| !matches!(component, Component::CurDir)) diff --git a/rust/fixtures/editor/html-preview-v1.json b/rust/fixtures/editor/html-preview-v1.json new file mode 100644 index 0000000..e7cc2cd --- /dev/null +++ b/rust/fixtures/editor/html-preview-v1.json @@ -0,0 +1,86 @@ +{ + "version": 1, + "description": "htmlPreview.site and htmlPreview.resolveRequest for HTML files previewed as static sites. Paths are slash-separated and relative to a fresh workspace; an entry of `files` ending in `/` is an empty folder, any other entry a file. `site.root` is relative to the workspace, empty for the workspace itself. A `file` answer's `path` is relative to the workspace and compared after resolving symbolic links.", + "files": [ + "site/index.html", + "site/page one.html", + "site/a%20b.html", + "site/css/app.css", + "site/app.mjs", + "site/data.bin", + "site/docs/index.html", + "site/empty/", + "site/server.pem", + "site/.env", + ".git/config", + ".github/pages/page.html", + ".github/pages/style.css" + ], + "cases": [ + { + "name": "a page in the workspace is served from the workspace", + "document": "site/page one.html", + "inWorkspace": true, + "site": { + "root": "", + "address": "site/page%20one.html", + "mime": "text/html; charset=utf-8" + }, + "requests": [ + { "path": "site/page one.html", "kind": "document" }, + { "path": "site/css/app.css", "kind": "file", "file": "site/css/app.css", "mime": "text/css; charset=utf-8" }, + { "path": "site/app.mjs", "kind": "file", "file": "site/app.mjs", "mime": "text/javascript; charset=utf-8" }, + { "path": "site/data.bin", "kind": "file", "file": "site/data.bin", "mime": "application/octet-stream" }, + { "path": "site/a%20b.html", "kind": "file", "file": "site/a%20b.html", "mime": "text/html; charset=utf-8" }, + { "path": "site/index.html", "kind": "file", "file": "site/index.html", "mime": "text/html; charset=utf-8" }, + { "path": "site/docs", "kind": "directory" }, + { "path": "site/docs/", "kind": "directory" }, + { "path": "site/empty", "kind": "none" }, + { "path": "", "kind": "none" }, + { "path": "site/missing.css", "kind": "none" }, + { "path": "../outside.css", "kind": "none" }, + { "path": "site/../../outside.css", "kind": "none" }, + { "path": "site\\..\\..\\outside.css", "kind": "none" }, + { "path": "C:/Windows/win.ini", "kind": "none" }, + { "path": ".git/config", "kind": "denied" }, + { "path": "site/.env", "kind": "denied" }, + { "path": "site/server.pem", "kind": "denied" }, + { "path": ".github/pages/page.html", "kind": "denied" } + ] + }, + { + "name": "a page in a hidden folder reaches its own folder but no other hidden one", + "document": ".github/pages/page.html", + "inWorkspace": true, + "site": { + "root": "", + "address": ".github/pages/page.html", + "mime": "text/html; charset=utf-8" + }, + "requests": [ + { "path": ".github/pages/page.html", "kind": "document" }, + { "path": ".github/pages/style.css", "kind": "file", "file": ".github/pages/style.css", "mime": "text/css; charset=utf-8" }, + { "path": ".git/config", "kind": "denied" }, + { "path": "site/.env", "kind": "denied" } + ] + }, + { + "name": "a page outside the workspace is served from its own folder", + "document": "site/index.html", + "inWorkspace": false, + "site": { + "root": "site", + "address": "index.html", + "mime": "text/html; charset=utf-8" + }, + "requests": [ + { "path": "index.html", "kind": "document" }, + { "path": "css/app.css", "kind": "file", "file": "site/css/app.css", "mime": "text/css; charset=utf-8" }, + { "path": "", "kind": "directory" }, + { "path": "../.git/config", "kind": "none" }, + { "path": ".env", "kind": "denied" } + ] + } + ], + "invalidDocuments": ["index.html"] +}