Merge pull request #3083 from skylartaylor/linked-path-checks

Resolve remote links and document-supplied output names safely
This commit is contained in:
skylar taylor-barrick authored and GitHub committed 2026-10-17 12:14:17 -06:00
commit bc7a12b94a
34 files changed
+243 -64

No files matched your search

+22 -22
View File
@@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT" licence_file = "LICENSE-MIT"
kind = "translation" kind = "translation"
usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "cefa955aa92790d6773cb1d1e15e19019b3165a00528d57d6617362e1d1988ab" sha256 = "a3234684d5318ce38213c79533d3eebb9c635497ba9e00fb9ca7ebe2fad9d73d"
# ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences.
# ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`).
@@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT" licence_file = "LICENSE-MIT"
kind = "translation" kind = "translation"
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "dbfebf4e9b2e9ac31ecf72202c2098173e72ebfaf90e9fee8f0563a6734fe31f" sha256 = "c5e0206846b009bcb366d092c3d64fd8b9e42d29d5406fb73ae36ecb1f2083d5"
[[asset]] [[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv" path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT" licence_file = "LICENSE-MIT"
kind = "translation" kind = "translation"
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "0b0dafa3806a6b3508684f64e1f07b435113f8efd0e59201d12250c63c1d9547" sha256 = "349a2ead371bdbfeefb9ab2279c676b205cefe163cbef174f8123fe4bf739ab9"
[[asset]] [[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv" path = "crates/ui-egui/src/i18n/cs.tsv"
@@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT" licence_file = "LICENSE-MIT"
kind = "translation" kind = "translation"
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "898a5240fa59cac42ef6b99cb60bcfca0094ac27370e1c8d020bd27936b56517" sha256 = "cb332907792d4aa760ca9862160e24a7b469afdbcfb93235e99deb8d58604ae2"
[[asset]] [[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv" path = "crates/ui-egui/src/i18n/pt-br.tsv"
@@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT" licence_file = "LICENSE-MIT"
kind = "translation" kind = "translation"
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "b4745c94797575529cd0e78c18c71fa2c0dfddfbf19b9c40b41dc2057c6f6543" sha256 = "ebd3a12e471ba9d9a69e6532fbac2d027dec3c91b79cdfbb1c6c386d13de3bd6"
[[asset]] [[asset]]
path = "crates/ui-egui/src/i18n/es.tsv" path = "crates/ui-egui/src/i18n/es.tsv"
@@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT" licence_file = "LICENSE-MIT"
kind = "translation" kind = "translation"
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "2acb3f6522d6d1f744343f474fe4c51ebec610a888a7fa83fc9422228e6fa3c1" sha256 = "f5e0553165ab0223bfbc809b1630170d1af11d6546ae5a682d4090104b6e4a05"
[[asset]] [[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv" path = "crates/ui-egui/src/i18n/fr.tsv"
@@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT" licence_file = "LICENSE-MIT"
kind = "translation" kind = "translation"
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "1b739478a4f186a49900cce84edbe136206a53b46340322df4caf77bb3fb5b12" sha256 = "dd2171a0a940a84e512fd6408a11cddf104be897ec4ab670c5ef6f1781ccb671"
[[asset]] [[asset]]
path = "crates/ui-egui/src/i18n/de.tsv" path = "crates/ui-egui/src/i18n/de.tsv"
@@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT" licence_file = "LICENSE-MIT"
kind = "translation" kind = "translation"
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "da3b71608ff7fe6e230b076207cee96ac01f66e068ca1be83af1ad56ac9c60eb" sha256 = "1b9b3d0d6dab626c3acb8c9aa64d64e56b98eb3f2faf71dcb1957067e72706db"
[[asset]] [[asset]]
path = "crates/ui-egui/src/i18n/te.tsv" path = "crates/ui-egui/src/i18n/te.tsv"
@@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT" licence_file = "LICENSE-MIT"
kind = "translation" kind = "translation"
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "989d2e5f57cf22c21a85951835ca9fbd05e314da67eb4c17332df1f03679fa00" sha256 = "668f23f4095120343e3f0910dc8f5844765370fae40400c48d685cad20dcf60e"
[[asset]] [[asset]]
path = "crates/ui-egui/src/i18n/it.tsv" path = "crates/ui-egui/src/i18n/it.tsv"
@@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT" licence_file = "LICENSE-MIT"
kind = "translation" kind = "translation"
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "5e84916e3ad8670216360695689fe8fd624365a29886f66a3c655ba126892f5b" sha256 = "02f3fd4b2721b0ea9c01c99d477a0339a28180514568179d3fc420e7fb81c671"
[[asset]] [[asset]]
path = "crates/ui-egui/src/i18n/pl.tsv" path = "crates/ui-egui/src/i18n/pl.tsv"
@@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT" licence_file = "LICENSE-MIT"
kind = "translation" kind = "translation"
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "988bcf2e0322a46c0464ff7b91ab5d90193b0ea7f454812d12595eb843de6fc7" sha256 = "26eb438c309d34e55eba959a21f139db91fa5fd1d6d4b7c3d1cd0fa79a60a5b0"
[[asset]] [[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv" path = "crates/ui-egui/src/i18n/ru.tsv"
@@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT" licence_file = "LICENSE-MIT"
kind = "translation" kind = "translation"
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "084f4ea06f31c632ad90b0e87f66240ce5b5dc012ac7770987d44918ee66f452" sha256 = "c04b6d0320bce999b33bd189bffcec8a196d4169e56cc65675dc920c5a671dac"
[[asset]] [[asset]]
path = "crates/ui-egui/src/i18n/hu.tsv" path = "crates/ui-egui/src/i18n/hu.tsv"
@@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT" licence_file = "LICENSE-MIT"
kind = "translation" kind = "translation"
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "973cd90ec34c8a973d146a0e2c73c1d632e17357c3d87ccf10c59e64d12c1587" sha256 = "37b042977d934b0b550e5f52faa5d90abbcb9e7ff61ff9588021610ce709e474"
[[asset]] [[asset]]
path = "crates/ui-egui/src/i18n/bg.tsv" path = "crates/ui-egui/src/i18n/bg.tsv"
@@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT" licence_file = "LICENSE-MIT"
kind = "translation" kind = "translation"
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "485f85d4053c9661ddda2ed9394a0644c77b489feb598d02f12ef209e11f2ee3" sha256 = "3d1f1d2558eec1271fa653bc38616feeefc6559deeb2a0cdbf07d67634e9ac97"
[[asset]] [[asset]]
path = "crates/ui-egui/src/i18n/kk.tsv" path = "crates/ui-egui/src/i18n/kk.tsv"
@@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT" licence_file = "LICENSE-MIT"
kind = "translation" kind = "translation"
usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "24524864022ab2abeff68b5979519eee4e92134d0c4053d40c741bc7f01c249c" sha256 = "03071249bd6c13f79da72262415e484fb1aa36098a37a49094b9dab58cebc4e2"
[[asset]] [[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv" path = "crates/ui-egui/src/i18n/ja.tsv"
@@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT" licence_file = "LICENSE-MIT"
kind = "translation" kind = "translation"
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "bc6d123bdb34d5d7c22717fed1a86ff20e7e493acc1e155e61e61e2dc74459aa" sha256 = "c0ba73b27b95b275f5eebc040ab1c3c6c3b227c63e1c637b4cdb27d0a9264a8b"
[[asset]] [[asset]]
path = "crates/ui-egui/src/i18n/uk.tsv" path = "crates/ui-egui/src/i18n/uk.tsv"
@@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT" licence_file = "LICENSE-MIT"
kind = "translation" kind = "translation"
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "82678dbe730f56062037ea78de165b2c016b3fa670fb1cbfc7cd713eca900bac" sha256 = "e3af76714ee9899328a453ec83195f4dc462fe48ba445fbe3688c777e74bc9dc"
[[asset]] [[asset]]
path = "crates/ui-egui/src/i18n/ar.tsv" path = "crates/ui-egui/src/i18n/ar.tsv"
@@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT" licence_file = "LICENSE-MIT"
kind = "translation" kind = "translation"
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "304d661b27cdb5246f6b2974b4b5e78667f0928cda3588440be3d8f35f254679" sha256 = "0855d7f785c433799da83400ebc932f987eb543008486bc4f553aadb4102c2ba"
[[asset]] [[asset]]
path = "crates/ui-egui/src/i18n/tr.tsv" path = "crates/ui-egui/src/i18n/tr.tsv"
@@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT" licence_file = "LICENSE-MIT"
kind = "translation" kind = "translation"
usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "05e2b7d5401166d362ba055120fba9a1645d6ec324997cf670e19fafcb0c54cf" sha256 = "884ab18d60db52f65cd0e30530e196c73c0e627f450bc948517e2f4b7deeb04b"
[[asset]] [[asset]]
path = "crates/ui-egui/src/i18n/lt.tsv" path = "crates/ui-egui/src/i18n/lt.tsv"
@@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT" licence_file = "LICENSE-MIT"
kind = "translation" kind = "translation"
usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "17c8579e0e9d1524ffbeb87e7b30c14562dbaac64400826ee786cecfe31648fa" sha256 = "c805a2c6da08e77a9a7455856d82c168cf6f353d96e1dc140fcc8898f12ba538"
[[asset]] [[asset]]
@@ -3352,7 +3352,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT" licence_file = "LICENSE-MIT"
kind = "translation" kind = "translation"
usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "2d1c4b4276d02512a4e20101be95a1671de5c4f5cdfd661887af5ebab0db06a6" sha256 = "11619064ae9edaeee5d601b7008e40095a40375a3e1a298155d59cb758fe7d34"
[[asset]] [[asset]]
path = "crates/ui-egui/src/i18n/sl.tsv" path = "crates/ui-egui/src/i18n/sl.tsv"
@@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT" licence_file = "LICENSE-MIT"
kind = "translation" kind = "translation"
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "8f8a259264cda06eb6d9a9bf91dc7d873a8561213a9a9e7acbd32623bef40be0" sha256 = "89f2c7495cfb0fc9d36d35b95c056dbeda18ccae521250babac719ff5df62d89"
[[asset]] [[asset]]
Generated
+6
View File
@@ -1142,6 +1142,11 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
[[package]] [[package]]
name = "craft-print" name = "craft-print"
version = "0.1.0" version = "0.1.0"
@@ -4100,6 +4105,7 @@ name = "pdfcraft-engine"
version = "0.8.0" version = "0.8.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"craft-docpath",
"icu_properties", "icu_properties",
"idna", "idna",
"image", "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" } craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" }
# Shared system spooler; document composition remains in pdfcraft-print. # Shared system spooler; document composition remains in pdfcraft-print.
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "666c358065261a2816b376fa3bb10538dbf53040" } 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 = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# internal # internal
pdfcraft-geom = { path = "crates/geom", version = "0.8.0" } pdfcraft-geom = { path = "crates/geom", version = "0.8.0" }
pdfcraft-filters = { path = "crates/filters", 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 // running and exits (#282, #317, #1726). Any option means the caller wants this launch's own
// window. // window.
let may_hand_off = !new_window && !files.is_empty() && options.is_empty() && control_file.is_none() && !create_images; 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::HandedOff => return Ok(()),
single_instance::Claim::Primary(server) => Some(server), single_instance::Claim::Primary(server) => Some(server),
single_instance::Claim::Alone => None, single_instance::Claim::Alone => None,
+1
View File
@@ -9,6 +9,7 @@ license.workspace = true
workspace = true workspace = true
[dependencies] [dependencies]
craft-docpath = { workspace = true }
pdfcraft-measure = { workspace = true } pdfcraft-measure = { workspace = true }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff"] }
pdfcraft-render = { workspace = true } pdfcraft-render = { workspace = true }
+57 -24
View File
@@ -137,7 +137,8 @@ pub enum RemoteDocumentPathError {
Empty, Empty,
TooLong, TooLong,
Hidden, Hidden,
UrlOrNetwork, /// A URL, a network or device path, or a path that climbs above its root.
NotLocal(craft_docpath::Refused),
MissingSourcePath, MissingSourcePath,
/// The target doesn't end in `.pdf`. /// The target doesn't end in `.pdf`.
NotPdf, NotPdf,
@@ -155,7 +156,7 @@ impl std::fmt::Display for RemoteDocumentPathError {
Self::Empty => f.write_str("the file specification is empty"), Self::Empty => f.write_str("the file specification is empty"),
Self::TooLong => f.write_str("the file specification is too long"), 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::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::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::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"), 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. /// 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> { pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result<PathBuf, RemoteDocumentPathError> {
if raw.is_empty() || raw.trim().is_empty() { if raw.is_empty() || raw.trim().is_empty() {
return Err(RemoteDocumentPathError::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) { if raw.chars().any(hides_text) {
return Err(RemoteDocumentPathError::Hidden); return Err(RemoteDocumentPathError::Hidden);
} }
if raw.starts_with("//") || raw.starts_with("\\\\") || raw.starts_with("/\\") || raw.starts_with("\\/") { // A GoToR file specification is a path, not a URL: any scheme of two or more characters is
return Err(RemoteDocumentPathError::UrlOrNetwork); // refused (one letter is a drive), `file:` and names such as `custom:target.pdf` included.
}
let path = Path::new(raw);
let drive_absolute = remote_document_path_is_absolute(raw);
if let Some((scheme, _)) = raw.split_once(':') { 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, '+' | '-' | '.')); && scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
let windows_drive = scheme.len() == 1 && drive_absolute; if is_scheme {
if valid_scheme && !windows_drive { return Err(RemoteDocumentPathError::NotLocal(craft_docpath::Refused::Url));
return Err(RemoteDocumentPathError::UrlOrNetwork);
} }
} }
if drive_absolute { let path = craft_docpath::resolve(raw, source).map_err(RemoteDocumentPathError::NotLocal)?;
return Ok(path.to_path_buf()); // 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)?; Ok(path)
let parent = source.parent().ok_or(RemoteDocumentPathError::MissingSourcePath)?;
Ok(parent.join(path))
} }
/// Largest linked PDF a GoToR link may open (1 GiB, the largest size the large-file work measures). /// 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 super::{RemoteDocumentPathError as E, resolve_remote_document_path as resolve};
use std::path::Path; use std::path::Path;
let source = Path::new("/docs/chapter/source.pdf"); let source = std::env::temp_dir().join("chapter").join("source.pdf");
assert_eq!(resolve(Some(source), "../target.pdf").as_deref(), Ok(Path::new("/docs/chapter/../target.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(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(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));
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"] { for url in
assert_eq!(resolve(Some(source), url), Err(E::UrlOrNetwork), "{url}"); ["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"]
}
for network_path in ["//server/share/a.pdf", "\\\\server\\share\\a.pdf", "\\\\?\\C:\\a.pdf", "\\\\.\\device\\a.pdf", "/\\server/share/a.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), "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)); 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] #[test]
fn urls_follow_the_artcraft_scheme() { fn urls_follow_the_artcraft_scheme() {
assert_eq!(super::APP_PAGE, format!("{}/apps/{}", super::WEBSITE, super::APP)); assert_eq!(super::APP_PAGE, format!("{}/apps/{}", super::WEBSITE, super::APP));
+1 -2
View File
@@ -738,8 +738,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
match action { match action {
Some(crate::sign_ui::CertAction::Trust(c)) => app.trust_certificate(*c), Some(crate::sign_ui::CertAction::Trust(c)) => app.trust_certificate(*c),
Some(crate::sign_ui::CertAction::Export(c)) => { Some(crate::sign_ui::CertAction::Export(c)) => {
let pem = crate::sign_ui::certificate_pem(&c); app.export_certificate(&c);
app.write_files(&[(format!("{}.cer", c.display_name()), std::sync::Arc::new(pem.into_bytes()))], "Export certificate");
} }
None => {} None => {}
} }
+49 -4
View File
@@ -195,9 +195,9 @@ pub(crate) fn file_stem_from_user(name: &str) -> String {
if reserved { format!("_{stem}") } else { stem.to_string() } if reserved { format!("_{stem}") } else { stem.to_string() }
} }
/// A safe native Save suggestion for a PDF-supplied attachment name. /// A plain file name for a PDF-supplied attachment name: the Save suggestion, and the tab name
/// This changes only the suggestion, never the stored name or the selected destination. /// (which later names files written from that tab). It never changes the stored name or a
#[cfg(any(test, not(target_arch = "wasm32")))] /// destination the user selected.
pub(crate) fn attachment_file_name(name: &str) -> String { pub(crate) fn attachment_file_name(name: &str) -> String {
let cleaned: String = name let cleaned: String = name
.chars() .chars()
@@ -217,6 +217,14 @@ pub(crate) fn attachment_file_name(name: &str) -> String {
if reserved { format!("_{name}") } else { name.to_string() } if reserved { format!("_{name}") } else { name.to_string() }
} }
/// Whether `name` names a file directly inside a folder: not empty, `.` or `..`, with no `/` or
/// `\\`, and no drive (`C:`). On Windows any `:` is refused, which also rules out alternate data
/// streams.
pub(crate) fn is_plain_file_name(name: &str) -> bool {
let drive = matches!(name.as_bytes(), [letter, b':', ..] if letter.is_ascii_alphabetic());
!(name.is_empty() || name == "." || name == ".." || name.contains(['/', '\\']) || drive || (cfg!(windows) && name.contains(':')))
}
/// Pages ▸ Rotate Pages. /// Pages ▸ Rotate Pages.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct RotateDraft { pub struct RotateDraft {
@@ -667,7 +675,12 @@ impl PdfCraftApp {
/// [`Self::write_files`], then `after` once every file is written: now, or on a later frame /// [`Self::write_files`], then `after` once every file is written: now, or on a later frame
/// when the user chooses the folder. `after` never runs when writing fails or is cancelled. /// when the user chooses the folder. `after` never runs when writing fails or is cancelled.
/// Every name must be a plain file name ([`is_plain_file_name`]); otherwise nothing is written.
pub(crate) fn write_files_then(&mut self, named: Vec<(String, Arc<Vec<u8>>)>, title: &str, after: impl FnOnce(&mut Self) + Send + 'static) { pub(crate) fn write_files_then(&mut self, named: Vec<(String, Arc<Vec<u8>>)>, title: &str, after: impl FnOnce(&mut Self) + Send + 'static) {
if let Some((name, _)) = named.iter().find(|(name, _)| !is_plain_file_name(name)) {
self.notify_fmt("Couldn't write {name}: {e}", &[("name", name), ("e", tl!("the name is not a plain file name"))]);
return;
}
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
{ {
let write = move |app: &mut Self, dir: std::path::PathBuf| { let write = move |app: &mut Self, dir: std::path::PathBuf| {
@@ -996,7 +1009,7 @@ impl PdfCraftApp {
#[cfg(test)] #[cfg(test)]
mod stem_tests { mod stem_tests {
use super::{attachment_file_name, file_stem_from_user}; use super::{attachment_file_name, file_stem_from_user, is_plain_file_name};
#[test] #[test]
fn user_file_names_stay_inside_the_folder_and_writable_everywhere() { fn user_file_names_stay_inside_the_folder_and_writable_everywhere() {
@@ -1049,4 +1062,36 @@ mod stem_tests {
assert_eq!(attachment_file_name(&once), once); assert_eq!(attachment_file_name(&once), once);
} }
} }
#[test]
fn write_files_refuses_names_that_leave_the_folder() {
let dir = std::env::temp_dir().join(format!("pdfcraft-write-files-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let out = dir.join("a").join("out");
std::fs::create_dir_all(&out).unwrap();
let absolute = dir.join("abs.pdf").to_string_lossy().into_owned();
let mut app = crate::PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.export_dir_override = Some(out.to_string_lossy().into_owned());
for bad in ["../x.pdf", "..", ".", "", "sub/x.pdf", "sub\\x.pdf", "C:x.pdf", absolute.as_str()] {
assert!(!is_plain_file_name(bad), "{bad}");
// A plain name first: nothing is written when any name in the batch is refused.
app.write_files(&[("ok.pdf".into(), std::sync::Arc::new(b"%PDF".to_vec())), (bad.into(), std::sync::Arc::new(b"%PDF".to_vec()))], "t");
assert!(app.toast.as_ref().is_some_and(|t| t.0.contains("not a plain file name")), "{bad}");
}
let names: Vec<_> = walk(&dir);
assert!(names.is_empty(), "{names:?}");
app.write_files(&[("x.pdf".into(), std::sync::Arc::new(b"%PDF".to_vec()))], "t");
assert_eq!(walk(&dir), vec![out.join("x.pdf")]);
let _ = std::fs::remove_dir_all(&dir);
}
fn walk(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() { out.extend(walk(&path)) } else { out.push(path) }
}
out
}
} }
+1
View File
@@ -1703,6 +1703,7 @@
Couldn't use {name}: {e} تعذّر استخدام {name}: {e} Couldn't use {name}: {e} تعذّر استخدام {name}: {e}
Couldn't extract pages: {e} تعذّر استخراج الصفحات: {e} Couldn't extract pages: {e} تعذّر استخراج الصفحات: {e}
Couldn't write {name}: {e} تعذّرت كتابة {name}: {e} Couldn't write {name}: {e} تعذّرت كتابة {name}: {e}
the name is not a plain file name الاسم ليس اسم ملف بسيطًا
Wrote 1 file to {dir} كُتب ملف واحد في {dir} Wrote 1 file to {dir} كُتب ملف واحد في {dir}
Wrote {n} files to {dir} كُتب {n} ملفات في {dir} Wrote {n} files to {dir} كُتب {n} ملفات في {dir}
No pages match those choices لا صفحات تطابق هذه الاختيارات No pages match those choices لا صفحات تطابق هذه الاختيارات
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1664,6 +1664,7 @@
Couldn't use {name}: {e} {name} не можа да бъде използван: {e} Couldn't use {name}: {e} {name} не можа да бъде използван: {e}
Couldn't extract pages: {e} Страниците не можаха да бъдат извлечени: {e} Couldn't extract pages: {e} Страниците не можаха да бъдат извлечени: {e}
Couldn't write {name}: {e} {name} не можа да бъде записан: {e} Couldn't write {name}: {e} {name} не можа да бъде записан: {e}
the name is not a plain file name името не е обикновено име на файл
Wrote 1 file to {dir} Записан е 1 файл в {dir} Wrote 1 file to {dir} Записан е 1 файл в {dir}
Wrote {n} files to {dir} Записани са {n} файла в {dir} Wrote {n} files to {dir} Записани са {n} файла в {dir}
No pages match those choices Няма страници, отговарящи на този избор No pages match those choices Няма страници, отговарящи на този избор
Can't render this file because it contains an unexpected character in line 8 and column 16.
+1
View File
@@ -1755,6 +1755,7 @@ key Space Mezerník
Couldn't use {name}: {e} {name} nelze použít: {e} Couldn't use {name}: {e} {name} nelze použít: {e}
Couldn't extract pages: {e} Stránky nelze extrahovat: {e} Couldn't extract pages: {e} Stránky nelze extrahovat: {e}
Couldn't write {name}: {e} {name} nelze zapsat: {e} Couldn't write {name}: {e} {name} nelze zapsat: {e}
the name is not a plain file name název není prostý název souboru
Wrote 1 file to {dir} Zapsán 1 soubor do {dir} Wrote 1 file to {dir} Zapsán 1 soubor do {dir}
Wrote {n} files to {dir} Zapsáno souborů: {n} (složka {dir}) Wrote {n} files to {dir} Zapsáno souborů: {n} (složka {dir})
No pages match those choices Těmto volbám neodpovídají žádné stránky No pages match those choices Těmto volbám neodpovídají žádné stránky
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1637,6 +1637,7 @@
Couldn't use {name}: {e} {name} konnte nicht verwendet werden: {e} Couldn't use {name}: {e} {name} konnte nicht verwendet werden: {e}
Couldn't extract pages: {e} Seiten konnten nicht extrahiert werden: {e} Couldn't extract pages: {e} Seiten konnten nicht extrahiert werden: {e}
Couldn't write {name}: {e} {name} konnte nicht geschrieben werden: {e} Couldn't write {name}: {e} {name} konnte nicht geschrieben werden: {e}
the name is not a plain file name der Name ist kein einfacher Dateiname
Wrote 1 file to {dir} 1 Datei nach {dir} geschrieben Wrote 1 file to {dir} 1 Datei nach {dir} geschrieben
Wrote {n} files to {dir} {n} Dateien nach {dir} geschrieben Wrote {n} files to {dir} {n} Dateien nach {dir} geschrieben
No pages match those choices Keine Seiten entsprechen dieser Auswahl No pages match those choices Keine Seiten entsprechen dieser Auswahl
Can't render this file because it contains an unexpected character in line 6 and column 51.
+1
View File
@@ -1215,6 +1215,7 @@
Couldn't use {name}: {e} Δεν ήταν δυνατή η χρήση του {name}: {e} Couldn't use {name}: {e} Δεν ήταν δυνατή η χρήση του {name}: {e}
Couldn't extract pages: {e} Δεν ήταν δυνατή η εξαγωγή σελίδων: {e} Couldn't extract pages: {e} Δεν ήταν δυνατή η εξαγωγή σελίδων: {e}
Couldn't write {name}: {e} Δεν ήταν δυνατή η εγγραφή του {name}: {e} Couldn't write {name}: {e} Δεν ήταν δυνατή η εγγραφή του {name}: {e}
the name is not a plain file name το όνομα δεν είναι απλό όνομα αρχείου
Wrote 1 file to {dir} Γράφτηκε 1 αρχείο στον φάκελο {dir} Wrote 1 file to {dir} Γράφτηκε 1 αρχείο στον φάκελο {dir}
Wrote {n} files to {dir} Γράφτηκαν {n} αρχεία στον φάκελο {dir} Wrote {n} files to {dir} Γράφτηκαν {n} αρχεία στον φάκελο {dir}
No pages match those choices Καμία σελίδα δεν αντιστοιχεί σε αυτές τις επιλογές No pages match those choices Καμία σελίδα δεν αντιστοιχεί σε αυτές τις επιλογές
Can't render this file because it contains an unexpected character in line 476 and column 9.
+1
View File
@@ -1714,6 +1714,7 @@ action wizard Start Iniciar
Couldn't use {name}: {e} No se pudo usar {name}: {e} Couldn't use {name}: {e} No se pudo usar {name}: {e}
Couldn't extract pages: {e} No se pudieron extraer las páginas: {e} Couldn't extract pages: {e} No se pudieron extraer las páginas: {e}
Couldn't write {name}: {e} No se pudo escribir {name}: {e} Couldn't write {name}: {e} No se pudo escribir {name}: {e}
the name is not a plain file name el nombre no es un nombre de archivo simple
Wrote 1 file to {dir} Se ha escrito 1 archivo en {dir} Wrote 1 file to {dir} Se ha escrito 1 archivo en {dir}
Wrote {n} files to {dir} Se han escrito {n} archivos en {dir} Wrote {n} files to {dir} Se han escrito {n} archivos en {dir}
No pages match those choices Ninguna página coincide con esas opciones No pages match those choices Ninguna página coincide con esas opciones
Can't render this file because it contains an unexpected character in line 6 and column 60.
+1
View File
@@ -1632,6 +1632,7 @@
Couldn't use {name}: {e} Impossible d'utiliser {name} : {e} Couldn't use {name}: {e} Impossible d'utiliser {name} : {e}
Couldn't extract pages: {e} Impossible d'extraire les pages : {e} Couldn't extract pages: {e} Impossible d'extraire les pages : {e}
Couldn't write {name}: {e} Impossible d'écrire {name} : {e} Couldn't write {name}: {e} Impossible d'écrire {name} : {e}
the name is not a plain file name le nom n'est pas un simple nom de fichier
Wrote 1 file to {dir} 1 fichier écrit dans {dir} Wrote 1 file to {dir} 1 fichier écrit dans {dir}
Wrote {n} files to {dir} {n} fichiers écrits dans {dir} Wrote {n} files to {dir} {n} fichiers écrits dans {dir}
No pages match those choices Aucune page ne correspond à ces choix No pages match those choices Aucune page ne correspond à ces choix
Can't render this file because it contains an unexpected character in line 6 and column 39.
+1
View File
@@ -1622,6 +1622,7 @@
Couldn't use {name}: {e} {name} használata nem sikerült: {e} Couldn't use {name}: {e} {name} használata nem sikerült: {e}
Couldn't extract pages: {e} Az oldalakat nem sikerült kinyerni: {e} Couldn't extract pages: {e} Az oldalakat nem sikerült kinyerni: {e}
Couldn't write {name}: {e} {name} írása nem sikerült: {e} Couldn't write {name}: {e} {name} írása nem sikerült: {e}
the name is not a plain file name a név nem egyszerű fájlnév
Wrote 1 file to {dir} 1 fájl ide írva: {dir} Wrote 1 file to {dir} 1 fájl ide írva: {dir}
Wrote {n} files to {dir} {n} fájl ide írva: {dir} Wrote {n} files to {dir} {n} fájl ide írva: {dir}
No pages match those choices Egy oldal sem felel meg ezeknek a választásoknak No pages match those choices Egy oldal sem felel meg ezeknek a választásoknak
Can't render this file because it contains an unexpected character in line 7 and column 44.
+1
View File
@@ -1709,6 +1709,7 @@
Couldn't use {name}: {e} Impossibile usare {name}: {e} Couldn't use {name}: {e} Impossibile usare {name}: {e}
Couldn't extract pages: {e} Impossibile estrarre le pagine: {e} Couldn't extract pages: {e} Impossibile estrarre le pagine: {e}
Couldn't write {name}: {e} Impossibile scrivere {name}: {e} Couldn't write {name}: {e} Impossibile scrivere {name}: {e}
the name is not a plain file name il nome non è un semplice nome file
Wrote 1 file to {dir} Scritto 1 file in {dir} Wrote 1 file to {dir} Scritto 1 file in {dir}
Wrote {n} files to {dir} Scritti {n} file in {dir} Wrote {n} files to {dir} Scritti {n} file in {dir}
No pages match those choices Nessuna pagina corrisponde a queste scelte No pages match those choices Nessuna pagina corrisponde a queste scelte
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1713,6 +1713,7 @@
Couldn't use {name}: {e} {name} を使用できませんでした: {e} Couldn't use {name}: {e} {name} を使用できませんでした: {e}
Couldn't extract pages: {e} ページを抽出できませんでした: {e} Couldn't extract pages: {e} ページを抽出できませんでした: {e}
Couldn't write {name}: {e} {name} に書き込めませんでした: {e} Couldn't write {name}: {e} {name} に書き込めませんでした: {e}
the name is not a plain file name 名前が単独のファイル名ではありません
Wrote 1 file to {dir} {dir} に 1 ファイルを書き込みました Wrote 1 file to {dir} {dir} に 1 ファイルを書き込みました
Wrote {n} files to {dir} {dir} に {n} ファイルを書き込みました Wrote {n} files to {dir} {dir} に {n} ファイルを書き込みました
No pages match those choices 条件に一致するページはありません No pages match those choices 条件に一致するページはありません
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1634,6 +1634,7 @@
Couldn't use {name}: {e} {name} файлын пайдалану мүмкін болмады: {e} Couldn't use {name}: {e} {name} файлын пайдалану мүмкін болмады: {e}
Couldn't extract pages: {e} Беттерді шығарып алу мүмкін болмады: {e} Couldn't extract pages: {e} Беттерді шығарып алу мүмкін болмады: {e}
Couldn't write {name}: {e} {name} файлына жазу мүмкін болмады: {e} Couldn't write {name}: {e} {name} файлына жазу мүмкін болмады: {e}
the name is not a plain file name атау қарапайым файл атауы емес
Wrote 1 file to {dir} 1 файл {dir} ішіне жазылды Wrote 1 file to {dir} 1 файл {dir} ішіне жазылды
Wrote {n} files to {dir} {n} файл {dir} ішіне жазылды Wrote {n} files to {dir} {n} файл {dir} ішіне жазылды
No pages match those choices Ешбір бет бұл таңдауларға сәйкес келмейді No pages match those choices Ешбір бет бұл таңдауларға сәйкес келмейді
Can't render this file because it contains an unexpected character in line 825 and column 9.
+1
View File
@@ -1965,6 +1965,7 @@ comment menu Edit Redaguoti
Couldn't use the photo: {e} Nepavyko panaudoti nuotraukos: {e} Couldn't use the photo: {e} Nepavyko panaudoti nuotraukos: {e}
Couldn't use {name}: {e} Nepavyko panaudoti {name}: {e} Couldn't use {name}: {e} Nepavyko panaudoti {name}: {e}
Couldn't write {name}: {e} Nepavyko įrašyti {name}: {e} Couldn't write {name}: {e} Nepavyko įrašyti {name}: {e}
the name is not a plain file name vardas nėra paprastas failo vardas
Couldn't write {path}: {e} Nepavyko įrašyti {path}: {e} Couldn't write {path}: {e} Nepavyko įrašyti {path}: {e}
Create link Kurti saitą Create link Kurti saitą
Created 1 link Sukurtas 1 saitas Created 1 link Sukurtas 1 saitas
Can't render this file because it contains an unexpected character in line 404 and column 9.
+1
View File
@@ -1696,6 +1696,7 @@
Couldn't use {name}: {e} Nie można użyć {name}: {e} Couldn't use {name}: {e} Nie można użyć {name}: {e}
Couldn't extract pages: {e} Nie można wyodrębnić stron: {e} Couldn't extract pages: {e} Nie można wyodrębnić stron: {e}
Couldn't write {name}: {e} Nie można zapisać {name}: {e} Couldn't write {name}: {e} Nie można zapisać {name}: {e}
the name is not a plain file name nazwa nie jest zwykłą nazwą pliku
Wrote 1 file to {dir} Zapisano 1 plik w {dir} Wrote 1 file to {dir} Zapisano 1 plik w {dir}
Wrote {n} files to {dir} Liczba plików zapisanych w {dir}: {n} Wrote {n} files to {dir} Liczba plików zapisanych w {dir}: {n}
No pages match those choices Żadna strona nie pasuje do wybranych opcji No pages match those choices Żadna strona nie pasuje do wybranych opcji
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1538,6 +1538,7 @@
Couldn't use {name}: {e} Não foi possível usar {name}: {e} Couldn't use {name}: {e} Não foi possível usar {name}: {e}
Couldn't extract pages: {e} Não foi possível extrair páginas: {e} Couldn't extract pages: {e} Não foi possível extrair páginas: {e}
Couldn't write {name}: {e} Não foi possível gravar {name}: {e} Couldn't write {name}: {e} Não foi possível gravar {name}: {e}
the name is not a plain file name o nome não é um nome de arquivo simples
Wrote 1 file to {dir} 1 arquivo gravado em {dir} Wrote 1 file to {dir} 1 arquivo gravado em {dir}
Wrote {n} files to {dir} {n} arquivos gravados em {dir} Wrote {n} files to {dir} {n} arquivos gravados em {dir}
No pages match those choices Nenhuma página corresponde a essas opções No pages match those choices Nenhuma página corresponde a essas opções
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -1704,6 +1704,7 @@
Couldn't use {name}: {e} Не удалось использовать {name}: {e} Couldn't use {name}: {e} Не удалось использовать {name}: {e}
Couldn't extract pages: {e} Не удалось извлечь страницы: {e} Couldn't extract pages: {e} Не удалось извлечь страницы: {e}
Couldn't write {name}: {e} Не удалось записать {name}: {e} Couldn't write {name}: {e} Не удалось записать {name}: {e}
the name is not a plain file name имя не является простым именем файла
Wrote 1 file to {dir} Записан 1 файл в {dir} Wrote 1 file to {dir} Записан 1 файл в {dir}
Wrote {n} files to {dir} Записано {n} файлов в {dir} Wrote {n} files to {dir} Записано {n} файлов в {dir}
No pages match those choices Страницы не соответствуют этим выбору No pages match those choices Страницы не соответствуют этим выбору
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -479,6 +479,7 @@
Couldn't take the snapshot: {e} Posnetka ni bilo mogoče usvariti: {e} Couldn't take the snapshot: {e} Posnetka ni bilo mogoče usvariti: {e}
Couldn't use {name}: {e} Datoteke {name} ni bilo mogoče uporabiti: {e} Couldn't use {name}: {e} Datoteke {name} ni bilo mogoče uporabiti: {e}
Couldn't write {name}: {e} Datoteke {name} ni bilo mogoče zapisati: {e} Couldn't write {name}: {e} Datoteke {name} ni bilo mogoče zapisati: {e}
the name is not a plain file name ime ni preprosto ime datoteke
Couldn't write {path}: {e} Poti {path} ni bilo mogoče zapisati: {e} Couldn't write {path}: {e} Poti {path} ni bilo mogoče zapisati: {e}
Counterclockwise 90 degrees 90 stopinj v levo Counterclockwise 90 degrees 90 stopinj v levo
Country/Region Država/regija Country/Region Država/regija
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -1623,6 +1623,7 @@ action wizard Start ప్రారంభించు
Couldn't use {name}: {e} {name}ను ఉపయోగించడం సాధ్యం కాలేదు: {e} Couldn't use {name}: {e} {name}ను ఉపయోగించడం సాధ్యం కాలేదు: {e}
Couldn't extract pages: {e} పేజీలను వేరు తీయడం సాధ్యం కాలేదు: {e} Couldn't extract pages: {e} పేజీలను వేరు తీయడం సాధ్యం కాలేదు: {e}
Couldn't write {name}: {e} {name}ను రాయడం సాధ్యం కాలేదు: {e} Couldn't write {name}: {e} {name}ను రాయడం సాధ్యం కాలేదు: {e}
the name is not a plain file name పేరు సాధారణ ఫైల్ పేరు కాదు
Wrote 1 file to {dir} {dir}లో 1 ఫైల్ సేవ్ చేయబడింది Wrote 1 file to {dir} {dir}లో 1 ఫైల్ సేవ్ చేయబడింది
Wrote {n} files to {dir} {dir}లో {n} ఫైల్‌లు సేవ్ చేయబడ్డాయి Wrote {n} files to {dir} {dir}లో {n} ఫైల్‌లు సేవ్ చేయబడ్డాయి
No pages match those choices ఆ ఎంపికలకు సరిపోయే పేజీలు ఏవీ లేవు No pages match those choices ఆ ఎంపికలకు సరిపోయే పేజీలు ఏవీ లేవు
Can't render this file because it contains an unexpected character in line 13 and column 16.
+1
View File
@@ -1603,6 +1603,7 @@
Couldn't use {name}: {e} {name} kullanılamadı: {e} Couldn't use {name}: {e} {name} kullanılamadı: {e}
Couldn't extract pages: {e} Sayfalar ayıklanamadı: {e} Couldn't extract pages: {e} Sayfalar ayıklanamadı: {e}
Couldn't write {name}: {e} {name} yazılamadı: {e} Couldn't write {name}: {e} {name} yazılamadı: {e}
the name is not a plain file name ad, basit bir dosya adı değil
Wrote 1 file to {dir} 1 dosya {dir} konumuna yazıldı Wrote 1 file to {dir} 1 dosya {dir} konumuna yazıldı
Wrote {n} files to {dir} {n} dosya {dir} konumuna yazıldı Wrote {n} files to {dir} {n} dosya {dir} konumuna yazıldı
No pages match those choices Bu seçimlerle eşleşen sayfa yok No pages match those choices Bu seçimlerle eşleşen sayfa yok
Can't render this file because it contains an unexpected character in line 824 and column 9.
+1
View File
@@ -1701,6 +1701,7 @@
Couldn't use {name}: {e} Не вдалося використати {name}: {e} Couldn't use {name}: {e} Не вдалося використати {name}: {e}
Couldn't extract pages: {e} Не вдалося видобути сторінки: {e} Couldn't extract pages: {e} Не вдалося видобути сторінки: {e}
Couldn't write {name}: {e} Не вдалося записати {name}: {e} Couldn't write {name}: {e} Не вдалося записати {name}: {e}
the name is not a plain file name назва не є простою назвою файлу
Wrote 1 file to {dir} Записано 1 файл у {dir} Wrote 1 file to {dir} Записано 1 файл у {dir}
Wrote {n} files to {dir} Записано файлів ({n}) у {dir} Wrote {n} files to {dir} Записано файлів ({n}) у {dir}
No pages match those choices Жодна сторінка не відповідає вибраним параметрам No pages match those choices Жодна сторінка не відповідає вибраним параметрам
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1955,6 +1955,7 @@ comment menu Edit Sửa
Couldn't use the photo: {e} Không thể dùng ảnh: {e} Couldn't use the photo: {e} Không thể dùng ảnh: {e}
Couldn't use {name}: {e} Không thể dùng {name}: {e} Couldn't use {name}: {e} Không thể dùng {name}: {e}
Couldn't write {name}: {e} Không thể ghi {name}: {e} Couldn't write {name}: {e} Không thể ghi {name}: {e}
the name is not a plain file name tên không phải là tên tệp đơn thuần
Couldn't write {path}: {e} Không thể ghi {path}: {e} Couldn't write {path}: {e} Không thể ghi {path}: {e}
Create link Tạo liên kết Create link Tạo liên kết
Created 1 link Đã tạo 1 liên kết Created 1 link Đã tạo 1 liên kết
Can't render this file because it contains an unexpected character in line 490 and column 12.
+1
View File
@@ -1671,6 +1671,7 @@
Couldn't use {name}: {e} 无法使用{name}:{e} Couldn't use {name}: {e} 无法使用{name}:{e}
Couldn't extract pages: {e} 无法提取页面:{e} Couldn't extract pages: {e} 无法提取页面:{e}
Couldn't write {name}: {e} 无法写入{name}:{e} Couldn't write {name}: {e} 无法写入{name}:{e}
the name is not a plain file name 名称不是单纯的文件名
Wrote 1 file to {dir} 已将 1 个文件写入{dir} Wrote 1 file to {dir} 已将 1 个文件写入{dir}
Wrote {n} files to {dir} 已将{n}个文件写入{dir} Wrote {n} files to {dir} 已将{n}个文件写入{dir}
No pages match those choices 所选条件没有匹配的页面 No pages match those choices 所选条件没有匹配的页面
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -1615,6 +1615,7 @@
Couldn't use {name}: {e} 無法使用{name}:{e} Couldn't use {name}: {e} 無法使用{name}:{e}
Couldn't extract pages: {e} 無法提取頁面:{e} Couldn't extract pages: {e} 無法提取頁面:{e}
Couldn't write {name}: {e} 無法寫入{name}:{e} Couldn't write {name}: {e} 無法寫入{name}:{e}
the name is not a plain file name 名稱不是單純的檔案名稱
Wrote 1 file to {dir} 已將 1 個檔案寫入{dir} Wrote 1 file to {dir} 已將 1 個檔案寫入{dir}
Wrote {n} files to {dir} 已將{n}個檔案寫入{dir} Wrote {n} files to {dir} 已將{n}個檔案寫入{dir}
No pages match those choices 所選條件沒有匹配的頁面 No pages match those choices 所選條件沒有匹配的頁面
Can't render this file because it contains an unexpected character in line 9 and column 16.
+2 -1
View File
@@ -1520,7 +1520,8 @@ impl PdfCraftApp {
match (data, open) { match (data, open) {
(Err(e), _) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]), (Err(e), _) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]),
(Ok(bytes), true) => { (Ok(bytes), true) => {
if let Err(e) = self.open_bytes(&att.name, None, bytes) { // The tab name later names files written from the tab (Split, Extract pages).
if let Err(e) = self.open_bytes(&crate::files::attachment_file_name(&att.name), None, bytes) {
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]); self.notify_fmt("Couldn't open {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]);
} }
} }
+1 -2
View File
@@ -784,8 +784,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
app.start_signing(page, None, Some(field), None); app.start_signing(page, None, Some(field), None);
} }
Some(crate::sign_ui::PanelAction::ExportCertificate(c)) => { Some(crate::sign_ui::PanelAction::ExportCertificate(c)) => {
let pem = crate::sign_ui::certificate_pem(&c); app.export_certificate(&c);
app.write_files(&[(format!("{}.cer", c.display_name()), std::sync::Arc::new(pem.into_bytes()))], "Export certificate");
} }
None => {} None => {}
} }
+23
View File
@@ -266,6 +266,12 @@ fn entry_unusable(u: &sign::windows::Unusable) -> DigitalIdEntry {
} }
impl PdfCraftApp { impl PdfCraftApp {
/// Export certificate: write `c` as PEM into a chosen folder, named after its subject.
pub(crate) fn export_certificate(&mut self, c: &Certificate) {
let name = format!("{}.cer", crate::files::attachment_file_name(&c.display_name()));
self.write_files(&[(name, std::sync::Arc::new(certificate_pem(c).into_bytes()))], "Export certificate");
}
/// Start signing: the rectangle (or field) is known; show Sign with a Digital ID. /// Start signing: the rectangle (or field) is known; show Sign with a Digital ID.
pub fn start_signing(&mut self, page: usize, rect: Option<[f64; 4]>, field: Option<String>, certify: Option<u8>) { pub fn start_signing(&mut self, page: usize, rect: Option<[f64; 4]>, field: Option<String>, certify: Option<u8>) {
self.refresh_os_key_store_ids(); self.refresh_os_key_store_ids();
@@ -1496,6 +1502,23 @@ mod tests {
Ok(PathBuf::from(&app.digital_ids[i].path)) Ok(PathBuf::from(&app.digital_ids[i].path))
} }
#[test]
fn an_exported_certificate_is_written_inside_the_chosen_folder() {
let dir = scratch();
let out = dir.join("out");
std::fs::create_dir_all(&out).unwrap();
let key = PrivateKey::generate_p256().unwrap();
let cert = Certificate::self_signed(&Name::build("../x", "", "", "", ""), &key, sign::Time::from_unix(0), 1, &[1]).unwrap();
let mut app = PdfCraftApp::new();
app.export_dir_override = Some(out.to_string_lossy().into_owned());
app.export_certificate(&cert);
let written: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect();
assert_eq!(written, vec![std::ffi::OsString::from("out")]);
let pem = std::fs::read_to_string(out.join(".._x.cer")).unwrap();
assert!(pem.starts_with("-----BEGIN CERTIFICATE-----"), "{pem}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test] #[test]
fn signature_status_style_gives_each_verdict_its_panel_icon() { fn signature_status_style_gives_each_verdict_its_panel_icon() {
assert_eq!(signature_status_style(SignatureStatus::Valid).0, "circle-check"); assert_eq!(signature_status_style(SignatureStatus::Valid).0, "circle-check");
+53 -8
View File
@@ -748,20 +748,26 @@ fn a_background_startup_forms_script_notice_waits_until_its_tab_is_selected() {
/// Embedded bytes and the PDF's original /UF name stay independent of the Save suggestion. /// Embedded bytes and the PDF's original /UF name stay independent of the Save suggestion.
fn attachment_fixture() -> Vec<u8> { fn attachment_fixture() -> Vec<u8> {
let name = "invoice\u{202e}fdp.exe"; attachment_pdf("invoice\u{202e}fdp.exe", b"hello notes")
}
/// A one-page PDF with one embedded file whose /UF name is `name`.
fn attachment_pdf(name: &str, data: &[u8]) -> Vec<u8> {
let uf = std::iter::once(0xfeffu16).chain(name.encode_utf16()).map(|c| format!("{c:04X}")).collect::<String>(); let uf = std::iter::once(0xfeffu16).chain(name.encode_utf16()).map(|c| format!("{c:04X}")).collect::<String>();
let objs = [ let objs: [Vec<u8>; 5] = [
"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(notes.txt) 5 0 R] >> >> >>".to_string(), b"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(notes.txt) 5 0 R] >> >> >>".to_vec(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> >>".to_string(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> >>".to_vec(),
"<< /Type /EmbeddedFile /Length 11 >>\nstream\nhello notes\nendstream".to_string(), [format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream"].concat(),
format!("<< /Type /Filespec /F (notes.txt) /UF <{uf}> /EF << /F 4 0 R >> >>"), format!("<< /Type /Filespec /F (notes.txt) /UF <{uf}> /EF << /F 4 0 R >> >>").into_bytes(),
]; ];
let mut out = b"%PDF-1.7\n".to_vec(); let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new(); let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() { for (i, o) in objs.iter().enumerate() {
offsets.push(out.len()); offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
} }
let xref = out.len(); let xref = out.len();
out.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n"); out.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n");
@@ -824,3 +830,42 @@ fn attachment_save_preserves_bytes_metadata_and_selected_destination() {
assert_eq!(doc.info.attachments[index].name, raw_name); assert_eq!(doc.info.attachments[index].name, raw_name);
assert_eq!(doc.bytes.as_ref(), &original); assert_eq!(doc.bytes.as_ref(), &original);
} }
/// Every file under `dir`, recursively, sorted.
fn files_under(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() { out.extend(files_under(&path)) } else { out.push(path) }
}
out.sort();
out
}
#[test]
fn files_written_from_an_attachment_tab_stay_inside_the_chosen_folder() {
let dir = AttachmentDir::new();
let absolute = dir.0.join("elsewhere").join("x.pdf").to_string_lossy().into_owned();
for name in ["../../x.pdf", absolute.as_str()] {
let out = dir.0.join("a").join("b").join("out");
std::fs::create_dir_all(&out).unwrap();
let mut h = harness(&[]);
h.state_mut().open_bytes("outer.pdf", None, attachment_pdf(name, &fixture(2))).unwrap();
h.run_steps(2);
let id = h.state().views[0].id;
h.state_mut().attachment_action(id, 0, true);
h.run_steps(2);
assert_eq!(h.state().views.len(), 2, "{name}: the attachment opens as a tab");
h.state_mut().export_dir_override = Some(out.to_string_lossy().into_owned());
h.state_mut().split_active(&pdfcraft_ui_egui::SplitPlan::By(pdfcraft_engine::SplitBy::PageCount(1)));
let written = files_under(&dir.0);
assert_eq!(written.len(), 2, "{name}: two split parts, got {written:?}");
assert!(written.iter().all(|p| p.parent() == Some(out.as_path())), "{name}: {written:?}");
// Extract writes page 1 under the name Split gave it.
h.state_mut().extract_draft = pdfcraft_ui_egui::ExtractDraft { separate: true, ..Default::default() };
h.state_mut().extract_selection();
assert!(h.state().toast.as_ref().is_some_and(|t| t.0.starts_with("Wrote 1 file")), "{name}: {:?}", h.state().toast);
assert_eq!(files_under(&dir.0), written, "{name}");
std::fs::remove_dir_all(dir.0.join("a")).unwrap();
}
}