mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
Generated
+5
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user