Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Release Notes

## [Unreleased]
### Fixed
- Fixed an input path in a subfolder aborting the process instead of compiling. The path was handed to Typst verbatim, and a Typst virtual path only accepts forward slashes, so on Windows an ordinary relative path such as `templates\letter.typ` panicked inside a native call and took the host process down with it. Paths are now resolved against the project root before being converted, and a path that genuinely leaves the root reports an error instead of panicking.

### Added
- Added `compiler.CompileToDocument(...)`, returning a disposable `TypstDocument` that exposes the rendered output while it is still in the memory the native library allocated. `GetOutputSpan`, `OpenOutputStream`, `CopyOutputTo`, `WriteOutputToFile` and `RentOutput` read it without putting a multi-megabyte PDF on the large object heap; `GetOutputBytes` copies when a `byte[]` is what you need.
- Added easier PDF compilation APIs on `TypstCompiler`:
Expand Down
34 changes: 20 additions & 14 deletions src/typst_core/src/world.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,23 +86,29 @@ impl SystemWorld {
fonts.extend(typst_kit::fonts::scan(path));
}

// Resolve the main file path relative to the root
// If the input path is absolute, try to make it relative to the root.
// If it's already relative, assume it's relative to the root.
// Resolve the main file path relative to the root. A relative input path is
// taken to be relative to the root, so joining it onto the root first leaves
// `virtualize` with a single job: translate a real path into a virtual one and
// check that it stays inside the root.
//
// Going through `Path` rather than handing the string to `VirtualPath::new`
// matters because a virtual path only accepts forward slashes. On Windows the
// separator in `templates\letter.typ` is an ordinary one, and `Path` splits on
// it; `VirtualPath::new` would instead reject the whole string.
//
// Input (Windows): root `C:\app`, path `templates\letter.typ`
// Output: virtual path `/templates/letter.typ`
let main_id = if let Some(path) = input_path {
let relative_path = if path.is_absolute() {
path.strip_prefix(&root).map_err(|_| {
eco_format!("input file must be contained in the project root")
})?
} else {
&path
};
let relative_str = relative_path.to_str().ok_or_else(|| {
eco_format!("input file path must be valid UTF-8")
let absolute = if path.is_absolute() { path } else { root.join(path) };
let virtual_path = VirtualPath::virtualize(&root, &absolute).map_err(|err| {
eco_format!("invalid input file path `{}`: {err}", absolute.display())
})?;
RootedPath::new(VirtualRoot::Project, VirtualPath::new(relative_str).unwrap()).intern()
RootedPath::new(VirtualRoot::Project, virtual_path).intern()
} else {
FileId::unique(RootedPath::new(VirtualRoot::Project, VirtualPath::new("<main>").unwrap()))
FileId::unique(RootedPath::new(
VirtualRoot::Project,
VirtualPath::new("<main>").expect("`<main>` is a valid virtual path"),
))
};

let mut slots = HashMap::new();
Expand Down
148 changes: 148 additions & 0 deletions src/typst_core/tests/input_path.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
//! Tests for how an on-disk input path is turned into Typst's virtual main file.
//!
//! A virtual path only accepts forward slashes and may not leave the project root.
//! Both constraints are properties of the path the caller supplies, so they have to
//! be reported as errors rather than taking the process down.

use std::ffi::{c_char, CString};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};

use typst_core::{Compiler, compile, create_compiler, free_compile_result, free_compiler};

/// A throwaway directory holding a template, removed when the test ends.
struct Project {
root: PathBuf,
}

impl Project {
/// Creates a project root containing `templates/letter.typ`.
fn new(name: &str) -> Self {
// A counter keeps parallel tests apart without pulling in a temp-file crate.
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);

let root = std::env::temp_dir().join(format!(
"typst_core-{}-{}-{}",
name,
std::process::id(),
unique
));
std::fs::create_dir_all(root.join("templates")).unwrap();
std::fs::write(root.join("templates").join("letter.typ"), "= Dear customer").unwrap();
Self { root }
}
}

impl Drop for Project {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}

/// Creates a compiler over a file, the way `TypstCompiler.FromFile` does.
fn compiler_for_file(root: &Path, input_path: &str) -> *mut Compiler {
let root = CString::new(root.to_str().unwrap()).unwrap();
let input_path = CString::new(input_path).unwrap();
let sys_inputs = CString::new("{}").unwrap();

create_compiler(
root.as_ptr(),
input_path.as_ptr(),
std::ptr::null(),
0,
std::ptr::null::<*const c_char>(),
0,
std::ptr::null(),
sys_inputs.as_ptr(),
true,
true,
)
}

/// Compiles to a PDF and returns its length, failing the test on a compiler error.
fn compile_to_pdf_len(compiler: *mut Compiler) -> usize {
let result = compile(compiler, std::ptr::null(), 96.0, std::ptr::null());

assert!(
result.error_ptr.is_null(),
"compilation failed: {}",
unsafe {
String::from_utf8_lossy(std::slice::from_raw_parts(
result.error_ptr,
result.error_len,
))
.into_owned()
}
);
assert_eq!(result.buffers_len, 1, "expected exactly one PDF buffer");

let len = unsafe { (*result.buffers).len };
free_compile_result(result);
len
}

/// A template in a subfolder is addressed with the platform's own separator. On
/// Windows that is a backslash, which a virtual path rejects outright, so the path
/// has to be resolved through `Path` rather than handed over as a string.
#[test]
fn nested_relative_input_path_is_compiled() {
let project = Project::new("nested-relative");
let input_path = Path::new("templates")
.join("letter.typ")
.to_str()
.unwrap()
.to_owned();

let compiler = compiler_for_file(&project.root, &input_path);
assert!(
!compiler.is_null(),
"`{input_path}` was rejected as an input path"
);

assert!(compile_to_pdf_len(compiler) > 0, "produced an empty PDF");
free_compiler(compiler);
}

/// The same file named absolutely resolves to the same document.
#[test]
fn absolute_input_path_inside_the_root_is_compiled() {
let project = Project::new("absolute-inside");
let input_path = project.root.join("templates").join("letter.typ");

let compiler = compiler_for_file(&project.root, input_path.to_str().unwrap());
assert!(!compiler.is_null(), "absolute input path was rejected");

assert!(compile_to_pdf_len(compiler) > 0, "produced an empty PDF");
free_compiler(compiler);
}

/// A relative path that climbs out of the root has to be refused. Reaching outside
/// the root is what the root is for, and the refusal must be an error result rather
/// than a panic: this runs inside an `extern "C"` function, where an unwind aborts
/// the host process.
#[test]
fn relative_input_path_escaping_the_root_is_refused() {
let project = Project::new("relative-escape");
let input_path = Path::new("..")
.join("outside.typ")
.to_str()
.unwrap()
.to_owned();

let compiler = compiler_for_file(&project.root, &input_path);
assert!(
compiler.is_null(),
"`{input_path}` escapes the root and must be refused"
);
}

/// An absolute path outside the root is refused for the same reason.
#[test]
fn absolute_input_path_outside_the_root_is_refused() {
let project = Project::new("absolute-outside");
let outside = std::env::temp_dir().join("typst_core-outside.typ");

let compiler = compiler_for_file(&project.root, outside.to_str().unwrap());
assert!(compiler.is_null(), "path outside the root must be refused");
}
80 changes: 80 additions & 0 deletions src/typstsharp.tests/Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,59 @@ public async Task CompilePdfStaticFromFile()
}
}

/// <summary>
/// A template kept in a subfolder is addressed with the platform's own separator,
/// which on Windows is a backslash. Typst's virtual paths only accept forward
/// slashes, so the path has to be translated rather than passed through verbatim.
/// </summary>
[Test]
public async Task NestedRelativeInputPathIsCompiled()
{
using var project = new ProjectDirectory();
project.AddTemplate(Path.Combine("templates", "letter.typ"), "= Dear customer");

using var compiler = TypstCompiler.FromFile(
Path.Combine("templates", "letter.typ"),
root: project.Path);

await Assert.That(GetPlainText(compiler.CompilePdf())).Contains("Dear customer");
}

/// <summary>
/// The same template addressed absolutely resolves to the same document.
/// </summary>
[Test]
public async Task AbsoluteInputPathInsideTheRootIsCompiled()
{
using var project = new ProjectDirectory();
var template = project.AddTemplate(Path.Combine("templates", "letter.typ"), "= Dear customer");

using var compiler = TypstCompiler.FromFile(template, root: project.Path);

await Assert.That(GetPlainText(compiler.CompilePdf())).Contains("Dear customer");
}

/// <summary>
/// Reaching outside the project root has to fail as an exception. The check runs
/// inside a native call, where an unwinding panic would abort the whole process
/// instead of failing the single request.
/// </summary>
[Test]
public async Task InputPathEscapingTheRootIsRejected()
{
using var project = new ProjectDirectory();
project.AddTemplate("letter.typ", "= Dear customer");
var nested = Path.Combine(project.Path, "templates");
Directory.CreateDirectory(nested);

await Assert.That(() =>
{
using var compiler = TypstCompiler.FromFile(
Path.Combine("..", "letter.typ"),
root: nested);
}).Throws<Exception>();
}

[Test]
public async Task CompilePdfToFileAndAsync()
{
Expand Down Expand Up @@ -751,6 +804,33 @@ private static string GetPlainText(byte[] pdf)
}
}

/// <summary>
/// A throwaway project root holding Typst templates, removed when the test ends.
/// </summary>
internal sealed class ProjectDirectory : IDisposable
{
public ProjectDirectory() => Directory.CreateDirectory(Path);

public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"typstsharp-project-{Guid.NewGuid():N}");

/// <summary>Writes a template at a root-relative path and returns its full path.</summary>
public string AddTemplate(string relativePath, string source)
{
var fullPath = System.IO.Path.Combine(Path, relativePath);
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(fullPath)!);
File.WriteAllText(fullPath, source);
return fullPath;
}

public void Dispose()
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
}

/// <summary>
/// A throwaway package directory laid out the way Typst expects: one directory level per
/// namespace, package name and version.
Expand Down