diff --git a/CONTEXT.md b/CONTEXT.md index 688c8967bbe4..e5ee41610901 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -35,9 +35,8 @@ Validation, chunking, indexing, rendering, … `typst-cnd` is the exporter library. The supported entry point is `typst compile --format cnd` (or an output path ending in `.cnd`), which brings every standard CLI option (`--root`, `--font-path`, -`--ignore-system-fonts`, `--deps`) plus two generic additions: -`--inputs-file` (a JSON object of string values for `sys.inputs`) and -`--fallback-dir` (missing project files are looked up by file name there). +`--ignore-system-fonts`, `--deps`) plus one generic addition: +`--inputs-file` (a JSON object of string values for `sys.inputs`). The standalone `typst-cnd` binary remains for existing callers. ## Where typst-cnd hooks into Typst diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index 8644ae16121e..4d7ee08c76aa 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -470,16 +470,6 @@ pub struct WorldArgs { #[clap(long = "root", env = "TYPST_ROOT", value_name = "DIR")] pub root: Option, - /// A directory searched for project files that do not exist. - /// - /// When a file of the project is missing, a file with the same name (the - /// last path component only) in this directory is used instead. A file - /// that exists in the project always wins, and package files never fall - /// back. Only regular files that resolve to a location inside this - /// directory are served. - #[clap(long = "fallback-dir", value_name = "DIR")] - pub fallback_dir: Option, - /// Add a string key-value pair visible through `sys.inputs`. #[clap( short = 'i', diff --git a/crates/typst-cli/src/watch.rs b/crates/typst-cli/src/watch.rs index e0cab8233c20..87b4bb223f4c 100644 --- a/crates/typst-cli/src/watch.rs +++ b/crates/typst-cli/src/watch.rs @@ -39,8 +39,7 @@ pub fn watch(command: &'static WatchCommand) -> HintedStrResult<()> { Ok(world) => break world, Err( ref err @ (WorldCreationError::InputNotFound(ref path) - | WorldCreationError::RootNotFound(ref path) - | WorldCreationError::FallbackDirNotFound(ref path)), + | WorldCreationError::RootNotFound(ref path)), ) => { watcher.update([path.clone()])?; Status::Error.print(&config).unwrap(); diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs index 2e8691b52b5c..600ea907e1a9 100644 --- a/crates/typst-cli/src/world.rs +++ b/crates/typst-cli/src/world.rs @@ -2,9 +2,8 @@ use std::any::Any; use std::collections::BTreeSet; use std::error; use std::fmt; -use std::fs; use std::io::{self, Read}; -use std::path::{Component, Path, PathBuf}; +use std::path::{Path, PathBuf}; use std::sync::{LazyLock, Mutex}; use ecow::{EcoString, eco_format}; @@ -232,9 +231,6 @@ static EMPTY_ID: LazyLock = LazyLock::new(|| { struct SystemFiles { main: FileId, project: FsRoot, - /// The canonical directory where missing project files are looked up by - /// file name. - fallback: Option, packages: SystemPackages, } @@ -284,59 +280,16 @@ impl SystemFiles { *EMPTY_ID }; - let fallback = - world_args.fallback_dir.as_deref().map(fallback_dir).transpose()?; - Ok(Self { main, project: FsRoot::new(root), - fallback, packages: crate::packages::system(&world_args.package), }) } - /// Resolves the file system path for the given `id`, including a file - /// served from the fallback directory. + /// Resolves the file system path for the given `id`. pub fn resolve(&self, id: FileId) -> FileResult { - let path = self.root(id)?.resolve(id.vpath())?; - Ok(self.fallback_file(id, &path).unwrap_or(path)) - } - - /// The fallback file that serves `id`, whose project path is `path`. - /// - /// Only a project file that does not exist falls back: a file that exists - /// wins, a directory stays an error, and an escaping path already failed - /// when its id was created. The file is looked up by its name alone and - /// is served only if it is a regular file whose real path lies inside the - /// fallback directory, so a symlink cannot lead out of it. - fn fallback_file(&self, id: FileId, path: &Path) -> Option { - let dir = self.fallback.as_deref()?; - if !matches!(id.root(), VirtualRoot::Project) - || id == *EMPTY_ID - || id == *STDIN_ID - { - return None; - } - // Any outcome but "not found" (an existing file or directory, a - // permission error, ...) keeps the project's own result. - match fs::metadata(path) { - Err(err) if err.kind() == io::ErrorKind::NotFound => {} - _ => return None, - } - let name = id.vpath().file_name()?; - let mut components = Path::new(name).components(); - if !matches!( - (components.next(), components.next()), - (Some(Component::Normal(_)), None) - ) { - return None; - } - let candidate = dir.join(name); - if candidate.parent() != Some(dir) { - return None; - } - let real = candidate.canonicalize().ok()?; - (real.starts_with(dir) && real.is_file()).then_some(real) + self.root(id)?.resolve(id.vpath()) } /// Resolves the root in which the given file ID resides. @@ -355,32 +308,11 @@ impl FileLoader for SystemFiles { } else if id == *STDIN_ID { read_from_stdin().map(Bytes::new) } else { - let root = self.root(id)?; - let path = root.resolve(id.vpath())?; - if let Some(real) = self.fallback_file(id, &path) { - return fs::read(&real) - .map(Bytes::new) - .map_err(|err| FileError::from_io(err, &real)); - } - root.load(id.vpath()) + self.root(id)?.load(id.vpath()) } } } -/// Canonicalizes the `--fallback-dir` and checks that it is a directory. -fn fallback_dir(dir: &Path) -> Result { - let canonical = dir.canonicalize().map_err(|err| match err.kind() { - io::ErrorKind::NotFound => { - WorldCreationError::FallbackDirNotFound(dir.to_path_buf()) - } - _ => WorldCreationError::Io(err), - })?; - if !canonical.is_dir() { - return Err(WorldCreationError::FallbackDirNotADirectory(dir.to_path_buf())); - } - Ok(canonical) -} - /// Read from stdin. fn read_from_stdin() -> FileResult> { let mut buf = Vec::new(); @@ -402,10 +334,6 @@ pub enum WorldCreationError { InputMalformed(VirtualizeError), /// The root directory does not appear to exist. RootNotFound(PathBuf), - /// The fallback directory does not appear to exist. - FallbackDirNotFound(PathBuf), - /// The fallback directory is not a directory. - FallbackDirNotADirectory(PathBuf), /// The requested creation timestamp was invalid. InvalidTimestamp, /// The `--inputs-file` could not be used. @@ -435,12 +363,6 @@ impl fmt::Display for WorldCreationError { WorldCreationError::RootNotFound(path) => { write!(f, "root directory not found (searched at {})", path.display()) } - WorldCreationError::FallbackDirNotFound(path) => { - write!(f, "fallback directory not found (searched at {})", path.display()) - } - WorldCreationError::FallbackDirNotADirectory(path) => { - write!(f, "fallback directory is not a directory ({})", path.display()) - } WorldCreationError::InvalidTimestamp => { write!(f, "creation timestamp out of range") } @@ -480,77 +402,3 @@ impl From for typst::Feature { } } } - -#[cfg(test)] -mod tests { - use typst::syntax::package::PackageSpec; - use typst_kit::downloader::SystemDownloader; - use typst_kit::packages::{FsPackages, UniversePackages}; - - use super::*; - - /// A loader with the project in `project`, `fallback`, and local packages - /// in `packages`. - fn files(project: &Path, fallback: &Path, packages: &Path) -> SystemFiles { - SystemFiles { - main: *EMPTY_ID, - project: FsRoot::new(project.canonicalize().unwrap()), - fallback: Some(fallback.canonicalize().unwrap()), - packages: SystemPackages::from_parts( - Some(FsPackages::new(packages.to_path_buf())), - None, - // Not `crate::download::downloader()`: it parses the process - // arguments, which exits a test binary. - UniversePackages::new(SystemDownloader::new("test")), - ), - } - } - - fn id(root: VirtualRoot, path: &str) -> FileId { - RootedPath::new(root, VirtualPath::new(path).unwrap()).intern() - } - - #[test] - fn project_files_fall_back_and_resolve_to_the_fallback_file() { - let fallback = tempfile::tempdir().unwrap(); - fs::write(fallback.path().join("lib.typ"), "x").unwrap(); - let project = tempfile::tempdir().unwrap(); - let files = files(project.path(), fallback.path(), fallback.path()); - let id = id(VirtualRoot::Project, "nested/lib.typ"); - assert_eq!(files.load(id).unwrap().as_slice(), b"x"); - assert_eq!( - files.resolve(id).unwrap(), - fallback.path().join("lib.typ").canonicalize().unwrap() - ); - } - - #[test] - fn packages_never_fall_back() { - let fallback = tempfile::tempdir().unwrap(); - fs::write(fallback.path().join("lib.typ"), "x").unwrap(); - let packages = tempfile::tempdir().unwrap(); - // The package exists, but the requested file does not. - fs::create_dir_all(packages.path().join("local/pkg/0.1.0")).unwrap(); - let project = tempfile::tempdir().unwrap(); - let files = files(project.path(), fallback.path(), packages.path()); - let spec: PackageSpec = "@local/pkg:0.1.0".parse().unwrap(); - let id = id(VirtualRoot::Package(spec), "lib.typ"); - assert!(matches!(files.load(id), Err(FileError::NotFound(_)))); - assert!(!files.resolve(id).unwrap().starts_with(fallback.path())); - } - - #[test] - fn special_ids_never_fall_back() { - let fallback = tempfile::tempdir().unwrap(); - // Decoys named like the special ids. Windows forbids `<` and `>` in - // file names, so there they cannot exist and the writes may fail. - let _ = fs::write(fallback.path().join(""), "x"); - let _ = fs::write(fallback.path().join(""), "x"); - let project = tempfile::tempdir().unwrap(); - let files = files(project.path(), fallback.path(), fallback.path()); - assert!(files.load(*EMPTY_ID).unwrap().is_empty()); - let fallback = fallback.path().canonicalize().unwrap(); - assert!(!files.resolve(*EMPTY_ID).unwrap().starts_with(&fallback)); - assert!(!files.resolve(*STDIN_ID).unwrap().starts_with(&fallback)); - } -} diff --git a/crates/typst-cli/tests/cnd.rs b/crates/typst-cli/tests/cnd.rs index 4df7cce015b3..16cb71b3dc7c 100644 --- a/crates/typst-cli/tests/cnd.rs +++ b/crates/typst-cli/tests/cnd.rs @@ -262,42 +262,6 @@ fn bad_inputs_file_fails_with_its_path() { ); } -/// A minimal image for `image(..)`. -const SVG_1X1: &str = r#""#; - -fn compile_with_fallback( - project: &Path, - main: &Path, - fallback: &Path, -) -> std::process::Output { - exec() - .arg("compile") - .arg(main) - .arg(project.join("out.pdf")) - .arg("--root") - .arg(project) - .arg("--fallback-dir") - .arg(fallback) - .arg("--deps") - .arg(project.join("deps.json")) - .arg("--deps-format") - .arg("json") - .output() - .unwrap() -} - -/// Compiles `main` to CND next to it, with a fallback directory. -fn compile_cnd_with_fallback(main: &Path, fallback: &Path) -> std::process::Output { - exec() - .arg("compile") - .arg(main) - .arg(main.with_file_name("out.cnd")) - .arg("--fallback-dir") - .arg(fallback) - .output() - .unwrap() -} - fn deps_inputs(project: &Path) -> Vec { let deps: serde_json::Value = serde_json::from_str( &std::fs::read_to_string(project.join("deps.json")).unwrap(), @@ -311,228 +275,6 @@ fn deps_inputs(project: &Path) -> Vec { .collect() } -#[test] -fn missing_project_file_is_found_by_name_in_the_fallback_dir() { - let project = tempfile::tempdir().unwrap(); - let fallback = tempfile::tempdir().unwrap(); - write(fallback.path(), "logo.svg", SVG_1X1); - // From a nested file, with a directory prefix that does not exist, and - // as an absolute project path. - write( - project.path(), - "chapters/intro.typ", - "#image(\"logo.svg\")\n#image(\"assets/logo.svg\")\n#image(\"/logo.svg\")", - ); - let main = write(project.path(), "main.typ", "#include \"chapters/intro.typ\""); - let out = compile_with_fallback(project.path(), &main, fallback.path()); - assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); - let fallback_logo = fallback.path().join("logo.svg").canonicalize().unwrap(); - let inputs = deps_inputs(project.path()); - assert!( - inputs - .iter() - .any(|p| Path::new(p).canonicalize().ok().as_ref() == Some(&fallback_logo)), - "deps must name the fallback file: {inputs:?}", - ); - // The missing project paths are not reported as inputs. - assert!( - !inputs - .iter() - .any(|p| p.contains("assets") || p.ends_with("chapters/logo.svg")), - "{inputs:?}" - ); -} - -#[test] -fn project_file_wins_over_the_fallback() { - let project = tempfile::tempdir().unwrap(); - let fallback = tempfile::tempdir().unwrap(); - write(fallback.path(), "data.txt", "from-fallback"); - write(project.path(), "data.txt", "from-project"); - let main = write(project.path(), "main.typ", "#read(\"data.txt\")"); - let out = compile_cnd_with_fallback(&main, fallback.path()); - assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); - let json = std::fs::read_to_string(project.path().join("out.cnd")).unwrap(); - assert!(json.contains("from-project") && !json.contains("from-fallback"), "{json}"); -} - -#[test] -fn fallback_serves_source_files_too() { - let project = tempfile::tempdir().unwrap(); - let fallback = tempfile::tempdir().unwrap(); - write(fallback.path(), "shared.typ", "#let greeting = \"from-shared\""); - let main = write( - project.path(), - "main.typ", - "#import \"lib/shared.typ\": greeting\n#greeting", - ); - let out = compile_cnd_with_fallback(&main, fallback.path()); - assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); - let json = std::fs::read_to_string(project.path().join("out.cnd")).unwrap(); - assert!(json.contains("from-shared"), "{json}"); -} - -#[test] -fn escaping_paths_and_directories_never_fall_back() { - let outer = tempfile::tempdir().unwrap(); - let project = outer.path().join("project"); - let fallback = tempfile::tempdir().unwrap(); - write(fallback.path(), "secret.txt", "leaked"); - write(fallback.path(), "folder", "leaked: a file named like the project's folder"); - write(outer.path(), "secret.txt", "outside"); - std::fs::create_dir_all(project.join("folder")).unwrap(); - - for source in [ - "#read(\"../secret.txt\")", - "#read(\"/x/../../secret.txt\")", - "#read(\"folder\")", - "#read(\"folder/\")", - "#read(\"/\")", - ] { - let main = write(&project, "main.typ", source); - let out = compile_cnd_with_fallback(&main, fallback.path()); - let stdout = String::from_utf8_lossy(&out.stdout); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(!out.status.success(), "{source} must fail: {stdout} {stderr}"); - assert!(!stderr.contains("leaked"), "{source}: {stderr}"); - assert!(!project.join("out.cnd").exists(), "{source}"); - } -} - -#[test] -fn directories_in_the_fallback_dir_are_not_served() { - let project = tempfile::tempdir().unwrap(); - let fallback = tempfile::tempdir().unwrap(); - std::fs::create_dir(fallback.path().join("data")).unwrap(); - let main = write(project.path(), "main.typ", "#read(\"data\")"); - let out = compile_cnd_with_fallback(&main, fallback.path()); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(!out.status.success()); - assert!(stderr.contains("file not found"), "{stderr}"); -} - -#[cfg(unix)] -#[test] -fn symlinks_leaving_the_fallback_dir_are_not_followed() { - let outer = tempfile::tempdir().unwrap(); - let fallback = outer.path().join("fallback"); - std::fs::create_dir(&fallback).unwrap(); - let secret = write(outer.path(), "secret.txt", "leaked"); - std::os::unix::fs::symlink(&secret, fallback.join("data.txt")).unwrap(); - std::os::unix::fs::symlink(outer.path(), fallback.join("up")).unwrap(); - let project = tempfile::tempdir().unwrap(); - - for source in ["#read(\"data.txt\")", "#read(\"up\")"] { - let main = write(project.path(), "main.typ", source); - let out = compile_cnd_with_fallback(&main, &fallback); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(!out.status.success(), "{source} must fail"); - assert!(stderr.contains("file not found"), "{source}: {stderr}"); - assert!(!stderr.contains("leaked"), "{source}: {stderr}"); - } -} - -#[cfg(unix)] -#[test] -fn symlinks_inside_the_fallback_dir_are_followed() { - let fallback = tempfile::tempdir().unwrap(); - write(fallback.path(), "real.txt", "from-fallback"); - std::os::unix::fs::symlink( - fallback.path().join("real.txt"), - fallback.path().join("data.txt"), - ) - .unwrap(); - let project = tempfile::tempdir().unwrap(); - let main = write(project.path(), "main.typ", "#read(\"data.txt\")"); - let out = compile_cnd_with_fallback(&main, fallback.path()); - assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); - let json = std::fs::read_to_string(project.path().join("out.cnd")).unwrap(); - assert!(json.contains("from-fallback"), "{json}"); -} - -#[test] -fn package_files_never_fall_back() { - let packages = tempfile::tempdir().unwrap(); - let pkg = packages.path().join("local/pkg/0.1.0"); - write( - &pkg, - "typst.toml", - "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\nentrypoint = \"lib.typ\"\n", - ); - write(&pkg, "lib.typ", "#let value = read(\"data.txt\")"); - let fallback = tempfile::tempdir().unwrap(); - write(fallback.path(), "data.txt", "leaked"); - let project = tempfile::tempdir().unwrap(); - let main = - write(project.path(), "main.typ", "#import \"@local/pkg:0.1.0\": value\n#value"); - let compile = || { - exec() - .arg("compile") - .arg(&main) - .arg(project.path().join("out.cnd")) - .arg("--fallback-dir") - .arg(fallback.path()) - .arg("--package-path") - .arg(packages.path()) - .output() - .unwrap() - }; - - let out = compile(); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(!out.status.success(), "a package file must not fall back"); - assert!(stderr.contains("file not found"), "{stderr}"); - - // The same package compiles once its own file exists. - write(&pkg, "data.txt", "from-package"); - let out = compile(); - assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); -} - -#[test] -fn missing_everywhere_keeps_the_usual_error() { - let project = tempfile::tempdir().unwrap(); - let fallback = tempfile::tempdir().unwrap(); - let main = write(project.path(), "main.typ", "#image(\"nowhere.png\")"); - let out = compile_with_fallback(project.path(), &main, fallback.path()); - assert!(!out.status.success()); - assert!(String::from_utf8_lossy(&out.stderr).contains("file not found")); -} - -#[test] -fn nonexistent_fallback_dir_is_reported() { - let project = tempfile::tempdir().unwrap(); - let main = write(project.path(), "main.typ", "x"); - let out = exec() - .arg("compile") - .arg(&main) - .arg("--fallback-dir") - .arg(project.path().join("nope")) - .output() - .unwrap(); - assert!(!out.status.success()); - assert!( - String::from_utf8_lossy(&out.stderr).contains("fallback directory not found") - ); -} - -#[test] -fn fallback_dir_must_be_a_directory() { - let project = tempfile::tempdir().unwrap(); - let main = write(project.path(), "main.typ", "x"); - let file = write(project.path(), "file.txt", "x"); - let out = exec() - .arg("compile") - .arg(&main) - .arg("--fallback-dir") - .arg(&file) - .output() - .unwrap(); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(!out.status.success()); - assert!(stderr.contains("fallback directory is not a directory"), "{stderr}"); -} - #[test] fn deps_lists_font_files_the_compile_loaded() { let project = tempfile::tempdir().unwrap(); @@ -595,8 +337,8 @@ fn deps_without_custom_fonts_lists_no_font_file() { } /// A font directory reached through a symlink or a relative path is reported -/// by its canonical path, like a fallback file from the same directory: -/// relative to `--root` when inside it, absolute otherwise. +/// by its canonical path, like a file dependency: relative to `--root` when +/// inside it, absolute otherwise. #[cfg(unix)] #[test] fn deps_report_font_files_by_their_canonical_path() { @@ -611,31 +353,19 @@ fn deps_report_font_files_by_their_canonical_path() { .clone(); for dir in [project.join("fonts"), outer.join("library/fonts")] { write(&dir, "custom.ttf", data); - write(&dir, "logo.svg", SVG_1X1); } std::os::unix::fs::symlink(outer.join("library"), outer.join("link")).unwrap(); - let main = write( - &project, - "main.typ", - format!("#set text(font: \"{family}\")\nHello\n#image(\"logo.svg\")"), - ); + let main = + write(&project, "main.typ", format!("#set text(font: \"{family}\")\nHello")); let absolute = |p: PathBuf| p.to_str().unwrap().to_owned(); let cases = [ // Outside the root, through a symlinked directory: absolute, canonical. - ( - outer.join("link/fonts"), - absolute(outer.join("library/fonts/custom.ttf")), - absolute(outer.join("library/fonts/logo.svg")), - ), + (outer.join("link/fonts"), absolute(outer.join("library/fonts/custom.ttf"))), // Inside the root, as a relative path: relative to the root. - ( - PathBuf::from("../project/fonts"), - "fonts/custom.ttf".to_owned(), - "fonts/logo.svg".to_owned(), - ), + (PathBuf::from("../project/fonts"), "fonts/custom.ttf".to_owned()), ]; - for (dir, font, logo) in cases { + for (dir, font) in cases { run_ok( exec() .current_dir(&project) @@ -647,15 +377,12 @@ fn deps_report_font_files_by_their_canonical_path() { .arg("--ignore-system-fonts") .arg("--font-path") .arg(&dir) - .arg("--fallback-dir") - .arg(&dir) .arg("--deps") .arg(project.join("deps.json")) .arg("--deps-format") .arg("json"), ); let inputs = deps_inputs(&project); - assert!(inputs.contains(&logo), "{}: {logo} in {inputs:?}", dir.display()); assert!(inputs.contains(&font), "{}: {font} in {inputs:?}", dir.display()); } }