fix(links): resolve remote document paths with the shared path check

GoToR file specifications go through craft-docpath: network, device and
object-manager paths (\??\, /??/) are refused without touching the
filesystem, relative targets resolve against the source document's
folder, and a result that is not absolute is refused rather than read
against the working directory. Files named on the command line open
under their absolute path, so their relative links keep resolving.
This commit is contained in:
aiongg
2026-10-16 18:16:50 +00:00
parent 08b44e858b
commit 265403813b
5 changed files with 69 additions and 25 deletions
Generated
+5
View File
@@ -1142,6 +1142,10 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-docpath"
version = "0.1.0"
[[package]]
name = "craft-print"
version = "0.1.0"
@@ -4100,6 +4104,7 @@ name = "pdfcraft-engine"
version = "0.8.0"
dependencies = [
"chrono",
"craft-docpath",
"icu_properties",
"idna",
"image",
+2
View File
@@ -18,6 +18,8 @@ craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0",
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" }
# Shared system spooler; document composition remains in pdfcraft-print.
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "666c358065261a2816b376fa3bb10538dbf53040" }
# Whether a path stored in a document may be opened (GoToR link targets).
craft-docpath = { path = "../secfix-craft-libs/crates/docpath" }
# internal
pdfcraft-geom = { path = "crates/geom", version = "0.8.0" }
pdfcraft-filters = { path = "crates/filters", version = "0.8.0" }
+4 -1
View File
@@ -231,7 +231,10 @@ fn main() -> eframe::Result {
// running and exits (#282, #317, #1726). Any option means the caller wants this launch's own
// window.
let may_hand_off = !new_window && !files.is_empty() && options.is_empty() && control_file.is_none() && !create_images;
let instance = match single_instance::claim(&single_instance::absolute(&files), may_hand_off) {
// Absolute for this instance too: links relative to a document resolve against its folder,
// never the working directory.
let files = single_instance::absolute(&files);
let instance = match single_instance::claim(&files, may_hand_off) {
single_instance::Claim::HandedOff => return Ok(()),
single_instance::Claim::Primary(server) => Some(server),
single_instance::Claim::Alone => None,
+1
View File
@@ -9,6 +9,7 @@ license.workspace = true
workspace = true
[dependencies]
craft-docpath = { workspace = true }
pdfcraft-measure = { workspace = true }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff"] }
pdfcraft-render = { workspace = true }
+57 -24
View File
@@ -137,7 +137,8 @@ pub enum RemoteDocumentPathError {
Empty,
TooLong,
Hidden,
UrlOrNetwork,
/// A URL, a network or device path, or a path that climbs above its root.
NotLocal(craft_docpath::Refused),
MissingSourcePath,
/// The target doesn't end in `.pdf`.
NotPdf,
@@ -155,7 +156,7 @@ impl std::fmt::Display for RemoteDocumentPathError {
Self::Empty => f.write_str("the file specification is empty"),
Self::TooLong => f.write_str("the file specification is too long"),
Self::Hidden => f.write_str("the file specification contains hidden or control characters"),
Self::UrlOrNetwork => f.write_str("only local document paths are supported"),
Self::NotLocal(r) => write!(f, "only local document paths are supported: {r}"),
Self::MissingSourcePath => f.write_str("a relative link needs a source document path"),
Self::NotPdf => f.write_str("the linked file is not a .pdf file"),
Self::NotAFile => f.write_str("the linked path is not a regular file"),
@@ -173,7 +174,9 @@ pub fn remote_document_path_is_absolute(raw: &str) -> bool {
}
/// Resolve a PDF remote-Go-To file specification to a local path without opening it.
/// Relative paths are based on the source document; an in-memory source has no fallback to CWD.
/// Relative paths are based on the source document, which should be an absolute path; an
/// in-memory source has no fallback to CWD. URLs (including `file:`), network and device paths are
/// refused ([`craft_docpath::resolve`]).
pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result<PathBuf, RemoteDocumentPathError> {
if raw.is_empty() || raw.trim().is_empty() {
return Err(RemoteDocumentPathError::Empty);
@@ -184,25 +187,22 @@ pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result<
if raw.chars().any(hides_text) {
return Err(RemoteDocumentPathError::Hidden);
}
if raw.starts_with("//") || raw.starts_with("\\\\") || raw.starts_with("/\\") || raw.starts_with("\\/") {
return Err(RemoteDocumentPathError::UrlOrNetwork);
}
let path = Path::new(raw);
let drive_absolute = remote_document_path_is_absolute(raw);
// A GoToR file specification is a path, not a URL: any scheme of two or more characters is
// refused (one letter is a drive), `file:` and names such as `custom:target.pdf` included.
if let Some((scheme, _)) = raw.split_once(':') {
let valid_scheme = scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
let is_scheme = scheme.len() > 1
&& scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
&& scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
let windows_drive = scheme.len() == 1 && drive_absolute;
if valid_scheme && !windows_drive {
return Err(RemoteDocumentPathError::UrlOrNetwork);
if is_scheme {
return Err(RemoteDocumentPathError::NotLocal(craft_docpath::Refused::Url));
}
}
if drive_absolute {
return Ok(path.to_path_buf());
let path = craft_docpath::resolve(raw, source).map_err(RemoteDocumentPathError::NotLocal)?;
// A relative result would be read against the working directory.
if !path.is_absolute() {
return Err(RemoteDocumentPathError::MissingSourcePath);
}
let source = source.ok_or(RemoteDocumentPathError::MissingSourcePath)?;
let parent = source.parent().ok_or(RemoteDocumentPathError::MissingSourcePath)?;
Ok(parent.join(path))
Ok(path)
}
/// Largest linked PDF a GoToR link may open (1 GiB, the largest size the large-file work measures).
@@ -503,23 +503,56 @@ mod tests {
use super::{RemoteDocumentPathError as E, resolve_remote_document_path as resolve};
use std::path::Path;
let source = Path::new("/docs/chapter/source.pdf");
assert_eq!(resolve(Some(source), "../target.pdf").as_deref(), Ok(Path::new("/docs/chapter/../target.pdf")));
let source = std::env::temp_dir().join("chapter").join("source.pdf");
let source = source.as_path();
let folder = source.parent().unwrap();
assert_eq!(resolve(Some(source), "target.pdf"), Ok(folder.join("target.pdf")));
assert_eq!(resolve(Some(source), "../target.pdf").as_deref(), Ok(folder.parent().unwrap().join("target.pdf").as_path()));
#[cfg(unix)]
assert_eq!(resolve(Some(source), "/manual/target.pdf").as_deref(), Ok(Path::new("/manual/target.pdf")));
assert_eq!(resolve(None, "target.pdf"), Err(E::MissingSourcePath));
assert_eq!(resolve(Some(Path::new("source.pdf")), "target.pdf"), Err(E::MissingSourcePath), "a relative source is not used");
assert_eq!(resolve(Some(source), ""), Err(E::Empty));
assert_eq!(resolve(Some(source), " "), Err(E::Empty));
for url in ["file:///tmp/a.pdf", "https://example.org/a.pdf", "smb://host/share/a.pdf", "ftp://host/a.pdf", "custom:target.pdf"] {
assert_eq!(resolve(Some(source), url), Err(E::UrlOrNetwork), "{url}");
}
for network_path in ["//server/share/a.pdf", "\\\\server\\share\\a.pdf", "\\\\?\\C:\\a.pdf", "\\\\.\\device\\a.pdf", "/\\server/share/a.pdf"]
for url in
["file:///tmp/a.pdf", "file://h/s/a.pdf", "https://example.org/a.pdf", "smb://host/share/a.pdf", "ftp://host/a.pdf", "custom:target.pdf"]
{
assert_eq!(resolve(Some(source), network_path), Err(E::UrlOrNetwork), "{network_path:?}");
assert!(matches!(resolve(Some(source), url), Err(E::NotLocal(_))), "{url}");
}
assert_eq!(resolve(Some(source), "folder/hidden\u{200B}.pdf"), Err(E::Hidden));
assert_eq!(resolve(Some(source), &"a".repeat(super::MAX_REMOTE_DOCUMENT_PATH_BYTES + 1)), Err(E::TooLong));
}
#[test]
fn remote_document_paths_to_other_computers_and_devices_are_refused() {
use super::{RemoteDocumentPathError as E, resolve_remote_document_path as resolve};
let source = std::env::temp_dir().join("chapter").join("source.pdf");
let source = source.as_path();
// Refused before any filesystem access: none of these exist, and a stat of a network path
// would contact its host.
for not_local in [
"//server/share/a.pdf",
"\\\\server\\share\\a.pdf",
"\\\\h\\s\\a.pdf",
"\\\\?\\UNC\\h\\s\\a.pdf",
"\\??\\UNC\\h\\s\\a.pdf",
"/??/x",
"\\??\\C:\\a.pdf",
"\\\\.\\device\\a.pdf",
"/\\server/share/a.pdf",
"NUL.pdf",
] {
assert!(matches!(resolve(Some(source), not_local), Err(E::NotLocal(_))), "{not_local:?}");
}
// Linux reads `//tmp/x` as `/tmp/x`, but on Windows it names a share.
#[cfg(unix)]
{
let doubled = format!("/{}", source.with_file_name("a.pdf").display());
assert!(matches!(resolve(Some(source), &doubled), Err(E::NotLocal(_))), "{doubled}");
}
}
#[test]
fn urls_follow_the_artcraft_scheme() {
assert_eq!(super::APP_PAGE, format!("{}/apps/{}", super::WEBSITE, super::APP));