mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 22:41:47 -06:00
A document could open any address with no confirmation: a link click, a push button's URI action, a one-line button script mapped to a URI action, or app.launchURL from JavaScript (on by default). That allowed sending form data to an outside site, or handing file:, smb: and app-handler addresses to the operating system. All four now go through PdfCraftApp::request_document_url: - engine::links::document_url accepts only http, https and mailto, up to 2048 characters, and refuses control, zero-width, bidi-override and non-space whitespace characters, malformed schemes and web addresses without a host. - Accepted addresses wait in an "Open this web page?" dialog that shows the real host (engine::links::host drops user:password@, and treats \ like / as browsers do, so https://a.example\@b.example shows a.example) and the full address. Cancel is the highlighted button; Escape and clicking outside cancel. While one request waits, further ones are dropped, so a script can't stack dialogs. - Refused addresses are reported in a notice and never opened. PdfCraft's own links (Help, About, updates) still use open_url. Preferences > JavaScript now says scripts need permission to open a web page. Tests: engine unit tests for the policy and host parsing, and crates/ui-egui/tests/document_links.rs driving the link, button, button script, app.launchURL and Escape paths (all fail against the old code). Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
70da0179a6
commit
cc7694be99
@@ -31,6 +31,113 @@ pub fn for_command(id: &str) -> Option<&'static Link> {
|
||||
LINKS.iter().find(|l| l.command == id)
|
||||
}
|
||||
|
||||
/// The kinds of address a document may ask PdfCraft to open: web pages and email. Anything
|
||||
/// else (`file:`, `javascript:`, `data:`, `smb:`, app handlers such as `ms-settings:`) is refused,
|
||||
/// because the operating system would hand it to whichever program claims it (#90, #91).
|
||||
pub const DOCUMENT_SCHEMES: &[&str] = &["https", "http", "mailto"];
|
||||
|
||||
/// The longest address a document may ask to open. A longer one is refused rather than shown
|
||||
/// cut short in the confirmation, where the hidden tail could carry the document's form data.
|
||||
pub const MAX_DOCUMENT_URL: usize = 2048;
|
||||
|
||||
/// Why an address that came from a document was refused.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum BlockedLink {
|
||||
Empty,
|
||||
/// Longer than [`MAX_DOCUMENT_URL`] characters.
|
||||
TooLong(usize),
|
||||
/// Control, zero-width or bidirectional-override characters, or whitespace other than a plain
|
||||
/// space, which can hide part of the address or make it read as something else.
|
||||
Hidden,
|
||||
/// No scheme, or a web address without a host or with a space before its path.
|
||||
Malformed,
|
||||
/// A scheme other than [`DOCUMENT_SCHEMES`] (lowercased).
|
||||
Scheme(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for BlockedLink {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Empty => write!(f, "the address is empty"),
|
||||
Self::TooLong(n) => write!(f, "the address is {n} characters long"),
|
||||
Self::Hidden => write!(f, "the address contains hidden or invisible characters"),
|
||||
Self::Malformed => write!(f, "the address isn't a complete web or email address"),
|
||||
Self::Scheme(s) => write!(f, "it is a “{s}:” address"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Characters that can hide or disguise part of an address in the confirmation dialog. A plain
|
||||
/// space is visible and turns up in real links (`mailto:…?subject=Order form`), so it is allowed.
|
||||
fn hides_text(c: char) -> bool {
|
||||
(c.is_whitespace() && c != ' ')
|
||||
|| c.is_control()
|
||||
|| matches!(c, '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2060}'..='\u{2064}' | '\u{2066}'..='\u{2069}' | '\u{FEFF}')
|
||||
}
|
||||
|
||||
/// Check an address that came from a document (a link, a button's URI action or a script's
|
||||
/// `app.launchURL`) before PdfCraft offers to open it. Returns the address trimmed of
|
||||
/// surrounding whitespace. PdfCraft's own links (Help, About, updates) don't come through here.
|
||||
pub fn document_url(raw: &str) -> Result<String, BlockedLink> {
|
||||
let url = raw.trim();
|
||||
if url.is_empty() {
|
||||
return Err(BlockedLink::Empty);
|
||||
}
|
||||
let len = url.chars().count();
|
||||
if len > MAX_DOCUMENT_URL {
|
||||
return Err(BlockedLink::TooLong(len));
|
||||
}
|
||||
if url.chars().any(hides_text) {
|
||||
return Err(BlockedLink::Hidden);
|
||||
}
|
||||
let (scheme, rest) = url.split_once(':').ok_or(BlockedLink::Malformed)?;
|
||||
// RFC 3986: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ).
|
||||
let well_formed = scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
|
||||
&& scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
|
||||
if !well_formed {
|
||||
return Err(BlockedLink::Malformed);
|
||||
}
|
||||
let scheme = scheme.to_ascii_lowercase();
|
||||
if !DOCUMENT_SCHEMES.contains(&scheme.as_str()) {
|
||||
return Err(BlockedLink::Scheme(scheme));
|
||||
}
|
||||
let complete = match scheme.as_str() {
|
||||
"mailto" => !rest.is_empty(),
|
||||
_ => authority(url).is_some_and(|a| !a.contains(' ')) && host(url).is_some(),
|
||||
};
|
||||
if !complete {
|
||||
return Err(BlockedLink::Malformed);
|
||||
}
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
/// The authority of a web address: what follows `//`, up to the first `/`, `\`, `?` or `#`.
|
||||
/// Browsers read `\` as `/` in web addresses (the WHATWG URL standard), so it ends the authority
|
||||
/// too; otherwise `https://other.example\@trusted.example/` would seem to go to
|
||||
/// `trusted.example`. `None` for a non-web address or one without `//`.
|
||||
fn authority(url: &str) -> Option<&str> {
|
||||
let (scheme, rest) = url.split_once(':')?;
|
||||
if !(scheme.eq_ignore_ascii_case("https") || scheme.eq_ignore_ascii_case("http")) {
|
||||
return None;
|
||||
}
|
||||
rest.strip_prefix("//")?.split(['/', '\\', '?', '#']).next()
|
||||
}
|
||||
|
||||
/// The host a web address really goes to, without any `user:password@` prefix, so
|
||||
/// `https://trusted.example@other.example/` reports `other.example`. `None` for a non-web
|
||||
/// address or one without a host.
|
||||
pub fn host(url: &str) -> Option<&str> {
|
||||
let authority = authority(url)?;
|
||||
let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
|
||||
let host = if host_port.starts_with('[') {
|
||||
// An IPv6 literal keeps its brackets; only a port after them is dropped.
|
||||
host_port.find(']').and_then(|i| host_port.get(..=i)).unwrap_or_default()
|
||||
} else {
|
||||
host_port.split(':').next().unwrap_or_default()
|
||||
};
|
||||
(!host.is_empty()).then_some(host)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
@@ -42,4 +149,78 @@ mod tests {
|
||||
assert!(crate::commands::command(l.command).is_some(), "{} is a registered command", l.command);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_may_offer_web_and_email_addresses() {
|
||||
use super::document_url;
|
||||
for ok in [
|
||||
"https://example.org",
|
||||
"HTTPS://Example.org/a?b=c#d",
|
||||
"http://example.org:8080/x",
|
||||
"mailto:someone@example.org",
|
||||
"https://[::1]:8443/",
|
||||
// Plain spaces turn up in real documents' links and are visible in the dialog.
|
||||
"mailto:orders@example.org?subject=Order form",
|
||||
"https://example.org/My Report.pdf",
|
||||
] {
|
||||
assert_eq!(document_url(ok).as_deref(), Ok(ok), "{ok}");
|
||||
}
|
||||
let multibyte = format!("https://example.org/{}", "é".repeat(super::MAX_DOCUMENT_URL - 20));
|
||||
assert!(document_url(&multibyte).is_ok(), "the limit counts characters, not bytes");
|
||||
assert_eq!(document_url(" https://example.org/ \n").as_deref(), Ok("https://example.org/"), "surrounding whitespace is trimmed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_may_not_offer_other_kinds_of_address() {
|
||||
use super::{BlockedLink as B, MAX_DOCUMENT_URL, document_url};
|
||||
let scheme = |s: &str| Err(B::Scheme(s.into()));
|
||||
assert_eq!(document_url("file:///C:/Windows/System32/calc.exe"), scheme("file"));
|
||||
assert_eq!(document_url("FILE://server/share/x.exe"), scheme("file"));
|
||||
assert_eq!(document_url("javascript:alert(1)"), scheme("javascript"));
|
||||
assert_eq!(document_url("data:text/html,<script>x</script>"), scheme("data"));
|
||||
assert_eq!(document_url("ms-settings:privacy"), scheme("ms-settings"));
|
||||
assert_eq!(document_url("smb://server/share"), scheme("smb"));
|
||||
assert_eq!(document_url(""), Err(B::Empty));
|
||||
assert_eq!(document_url(" "), Err(B::Empty));
|
||||
for bad in [
|
||||
"example.org",
|
||||
"//example.org",
|
||||
"1http://x",
|
||||
"ht tp://x",
|
||||
"https:",
|
||||
"https://",
|
||||
"https:///path",
|
||||
"https://user@/x",
|
||||
"mailto:",
|
||||
"https://trusted.example /x",
|
||||
] {
|
||||
assert_eq!(document_url(bad), Err(B::Malformed), "{bad}");
|
||||
}
|
||||
for hidden in [
|
||||
"https://exa\u{202E}gro.elpmaxe",
|
||||
"https://example.org\u{200B}.evil",
|
||||
"https://a.org/\u{0007}",
|
||||
"https://a.org/x\ty",
|
||||
"https://a.org/x\ny",
|
||||
"https://example.org/a\u{00A0}b",
|
||||
] {
|
||||
assert_eq!(document_url(hidden), Err(B::Hidden), "{hidden:?}");
|
||||
}
|
||||
let long = format!("https://example.org/?q={}", "a".repeat(MAX_DOCUMENT_URL));
|
||||
assert!(matches!(document_url(&long), Err(B::TooLong(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_host_shown_is_the_one_the_address_goes_to() {
|
||||
use super::host;
|
||||
assert_eq!(host("https://example.org/path"), Some("example.org"));
|
||||
assert_eq!(host("https://trusted.example@other.example/"), Some("other.example"));
|
||||
assert_eq!(host("http://user:pw@other.example:8080?x"), Some("other.example"));
|
||||
assert_eq!(host("https://[::1]:8443/"), Some("[::1]"));
|
||||
// Browsers read `\` as `/`, so this goes to other.example, not trusted.example.
|
||||
assert_eq!(host("https://other.example\\@trusted.example/"), Some("other.example"));
|
||||
assert_eq!(host("https://other.example\\x@trusted.example/"), Some("other.example"));
|
||||
assert_eq!(host("mailto:a@example.org"), None);
|
||||
assert_eq!(host("https:///nohost"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1600,7 +1600,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
}
|
||||
match clicked_link {
|
||||
Some(LinkTarget::Page(p)) => view.go_to_page(p),
|
||||
Some(LinkTarget::Uri(u)) => ui.ctx().open_url(egui::OpenUrl::new_tab(u)),
|
||||
Some(LinkTarget::Uri(u)) => app.request_document_url(&u, crate::LinkOrigin::Link),
|
||||
Some(LinkTarget::Other(s)) => app.notify(format!("{s} actions run in the JavaScript engine (M6)")),
|
||||
None => {}
|
||||
}
|
||||
@@ -1687,13 +1687,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
app.notify(n);
|
||||
}
|
||||
if let Some((name, action)) = app.views[index].forms.button.take() {
|
||||
run_button(app, index, ui.ctx(), &name, action);
|
||||
run_button(app, index, &name, action);
|
||||
}
|
||||
quick_bar(app, avail, ui);
|
||||
}
|
||||
|
||||
/// Run a push button's action (the ones that need no JavaScript engine).
|
||||
fn run_button(app: &mut PdfCraftApp, index: usize, ctx: &egui::Context, name: &str, action: pdfcraft_engine::form_scripts::ButtonAction) {
|
||||
fn run_button(app: &mut PdfCraftApp, index: usize, name: &str, action: pdfcraft_engine::form_scripts::ButtonAction) {
|
||||
use pdfcraft_engine::form_scripts::ButtonAction as B;
|
||||
let pages = app.session.get(app.views[index].id).map_or(0, |d| d.info.pages.len());
|
||||
match action {
|
||||
@@ -1716,7 +1716,7 @@ fn run_button(app: &mut PdfCraftApp, index: usize, ctx: &egui::Context, name: &s
|
||||
"LastPage" => app.views[index].go_to_page(pages.saturating_sub(1)),
|
||||
other => app.notify(format!("{name}: the {other} action isn't supported yet")),
|
||||
},
|
||||
B::Uri(u) => ctx.open_url(egui::OpenUrl::new_tab(u)),
|
||||
B::Uri(u) => app.request_document_url(&u, crate::LinkOrigin::Button),
|
||||
B::GoTo(p) => app.views[index].go_to_page(p.min(pages.saturating_sub(1))),
|
||||
B::Alert(m) => app.notify(m),
|
||||
B::Submit(url) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"];
|
||||
pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
password(app, ctx);
|
||||
save_prompt(app, ctx);
|
||||
link_prompt(app, ctx);
|
||||
crate::updates::dialog(app, ctx);
|
||||
let Some(dialog) = app.dialog else {
|
||||
app.props_draft = None;
|
||||
@@ -1315,6 +1316,57 @@ fn save_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// "Open this web page?" when a document's link, button or script asks to open an address
|
||||
/// (#90, #91). Shows where the address really goes and the whole address; Cancel is the default,
|
||||
/// and Escape or clicking outside cancels.
|
||||
fn link_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
let Some(pending) = app.pending_link.clone() else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let email = pending.url.get(..7).is_some_and(|s| s.eq_ignore_ascii_case("mailto:"));
|
||||
let (title, open) = if email { ("Write this email?", "Open email app") } else { ("Open this web page?", "Open link") };
|
||||
let mut choice: Option<bool> = None;
|
||||
let modal = egui::Modal::new(egui::Id::new("link_prompt")).show(ctx, |ui| {
|
||||
ui.set_width(460.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(crate::icons::image("external-link", 22.0, t.accent));
|
||||
ui.label(egui::RichText::new(title).font(theme::semibold(16.0)));
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let what = if email { "your email app with this address" } else { "this address in your browser" };
|
||||
ui.label(format!("{} in this document wants to open {what}:", pending.origin.noun()));
|
||||
ui.add_space(6.0);
|
||||
if let Some(host) = pdfcraft_engine::links::host(&pending.url) {
|
||||
ui.label(egui::RichText::new(host).font(theme::semibold(14.0)));
|
||||
}
|
||||
egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| {
|
||||
ui.add(egui::Label::new(egui::RichText::new(&pending.url).monospace().small()).wrap().selectable(true));
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"Only continue if you trust this document. An address can carry information from the document, such as what you typed into its form.",
|
||||
)
|
||||
.color(t.text_muted)
|
||||
.small(),
|
||||
);
|
||||
ui.add_space(14.0);
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if widgets::pill_button(ui, "Cancel", true).clicked() {
|
||||
choice = Some(false);
|
||||
}
|
||||
if widgets::pill_button(ui, open, false).clicked() {
|
||||
choice = Some(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
if choice.is_none() && modal.should_close() {
|
||||
choice = Some(false);
|
||||
}
|
||||
if let Some(open) = choice {
|
||||
app.resolve_pending_link(open);
|
||||
}
|
||||
}
|
||||
|
||||
fn yes(b: bool) -> String {
|
||||
if b { "Yes".into() } else { "No".into() }
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ pub struct DocJsDraft {
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// Act on what scripts produced in document `id`: alerts are shown, console output goes to
|
||||
/// the console, and print / page / link requests are carried out (form submissions are
|
||||
/// reported, never sent).
|
||||
/// the console, print and page requests are carried out, links wait for the user's permission
|
||||
/// (form submissions are reported, never sent).
|
||||
pub fn handle_js(&mut self, id: DocId, out: JsOutput) {
|
||||
if out.is_empty() {
|
||||
return;
|
||||
@@ -43,11 +43,7 @@ impl PdfCraftApp {
|
||||
self.views[i].go_to_page(p);
|
||||
}
|
||||
}
|
||||
Request::LaunchUrl(u) => {
|
||||
if let Some(ctx) = &self.ctx {
|
||||
ctx.open_url(egui::OpenUrl::new_tab(u));
|
||||
}
|
||||
}
|
||||
Request::LaunchUrl(u) => self.request_document_url(&u, crate::LinkOrigin::Script),
|
||||
Request::Submit(u) => self.notify(format!(
|
||||
"The form asks to be submitted to {u}; PdfCraft doesn't send form data. Save the document to keep your entries."
|
||||
)),
|
||||
@@ -251,7 +247,7 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok
|
||||
app.session.set_javascript(on);
|
||||
}
|
||||
ui.label(
|
||||
egui::RichText::new("Scripts run in a sandbox without file or network access. With JavaScript off, Acrobat's standard format, validate and calculate functions still work.")
|
||||
egui::RichText::new("Scripts run in a sandbox without file or network access. A script that asks to open a web page needs your permission first. With JavaScript off, Acrobat's standard format, validate and calculate functions still work.")
|
||||
.small()
|
||||
.color(t.text_muted),
|
||||
);
|
||||
|
||||
@@ -440,6 +440,36 @@ pub struct PdfCraftApp {
|
||||
pub replace_draft: Option<files::ReplaceDraft>,
|
||||
/// The last web link the app asked the system to open (tests and automation).
|
||||
pub last_opened_url: Option<String>,
|
||||
/// A document asked to open this address; the user hasn't answered yet (#90, #91).
|
||||
pub pending_link: Option<PendingLink>,
|
||||
}
|
||||
|
||||
/// Where in a document a request to open an address came from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LinkOrigin {
|
||||
/// A link on the page.
|
||||
Link,
|
||||
/// A push button's URI action.
|
||||
Button,
|
||||
/// A script (`app.launchURL`).
|
||||
Script,
|
||||
}
|
||||
|
||||
impl LinkOrigin {
|
||||
fn noun(self) -> &'static str {
|
||||
match self {
|
||||
Self::Link => "A link",
|
||||
Self::Button => "A button",
|
||||
Self::Script => "A script",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An address a document asked to open, waiting for the user to allow or cancel it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PendingLink {
|
||||
pub url: String,
|
||||
pub origin: LinkOrigin,
|
||||
}
|
||||
|
||||
/// Settings for the Number pages dialog.
|
||||
@@ -543,6 +573,7 @@ impl PdfCraftApp {
|
||||
control: None,
|
||||
bookmark_rename: None,
|
||||
last_opened_url: None,
|
||||
pending_link: None,
|
||||
protect_draft: Default::default(),
|
||||
boxes_draft: Default::default(),
|
||||
marks_draft: Default::default(),
|
||||
@@ -799,7 +830,8 @@ impl PdfCraftApp {
|
||||
client
|
||||
}
|
||||
|
||||
/// Open a web link in the system browser (a new tab on the web).
|
||||
/// Open a web link in the system browser (a new tab on the web). Only for PdfCraft's own
|
||||
/// links; an address that came from a document goes through [`Self::request_document_url`].
|
||||
pub fn open_url(&mut self, url: &str) {
|
||||
if let Some(ctx) = &self.ctx {
|
||||
ctx.open_url(egui::OpenUrl::new_tab(url));
|
||||
@@ -807,6 +839,33 @@ impl PdfCraftApp {
|
||||
self.last_opened_url = Some(url.to_string());
|
||||
}
|
||||
|
||||
/// A document asks to open `url` (a link, a button's URI action or `app.launchURL`). Web and
|
||||
/// email addresses wait for the user to allow them; anything else is refused with a notice
|
||||
/// (#90, #91). While one request is waiting, further ones are dropped, so a script can't
|
||||
/// queue up a stream of dialogs.
|
||||
pub fn request_document_url(&mut self, url: &str, origin: LinkOrigin) {
|
||||
match pdfcraft_engine::links::document_url(url) {
|
||||
Ok(url) => {
|
||||
if self.pending_link.is_none() {
|
||||
self.pending_link = Some(PendingLink { url, origin });
|
||||
}
|
||||
}
|
||||
Err(e) => self.notify(format!(
|
||||
"{} in this document tried to open an address PdfCraft won't open: {e}. Only web (http, https) and email (mailto) links open from documents.",
|
||||
origin.noun()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's answer to [`Self::pending_link`]: open it, or drop it.
|
||||
pub fn resolve_pending_link(&mut self, open: bool) {
|
||||
if let Some(p) = self.pending_link.take()
|
||||
&& open
|
||||
{
|
||||
self.open_url(&p.url);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn notify(&mut self, msg: impl Into<String>) {
|
||||
self.toast = Some((msg.into(), 0.0));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Addresses a document asks to open (links, button URI actions, `app.launchURL`) wait for the
|
||||
//! user's permission, and anything but a web or email address is refused (#90, #91).
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use pdfcraft_ui_egui::{LinkOrigin, PdfCraftApp, PendingLink};
|
||||
|
||||
/// See `tests/view.rs`: WARP's shader JIT crashes when two devices compile at once on ARM64.
|
||||
static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn gpu() -> std::sync::MutexGuard<'static, ()> {
|
||||
GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
const LINK_URL: &str = "https://example.org/from-a-link";
|
||||
const BUTTON_URL: &str = "https://example.org/from-a-button";
|
||||
|
||||
/// One 300×400 page with a web link (x 50..250, y 300..350), a push button with a URI action
|
||||
/// ("web") and a push button whose JavaScript asks to open a local program ("sneaky").
|
||||
fn fixture() -> Vec<u8> {
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) >> >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>".to_string(),
|
||||
format!("<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI ({LINK_URL}) >> >>"),
|
||||
format!("<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (web) /Rect [20 200 100 220] /P 3 0 R /A << /S /URI /URI ({BUTTON_URL}) >> >>"),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (sneaky) /Rect [120 200 200 220] /P 3 0 R /AA << /U << /S /JavaScript /JS (app.launchURL\\(\"file:///C:/Windows/System32/calc.exe\", true\\);) >> >> >>".to_string(),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offs.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let x = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offs {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn harness() -> Harness<'static, PdfCraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.open_bytes("links.pdf", None, fixture()).unwrap();
|
||||
app.set_option("left", "closed").unwrap();
|
||||
app.set_option("panel", "none").unwrap();
|
||||
app.set_option("zoom", "150").unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(8);
|
||||
h
|
||||
}
|
||||
|
||||
fn click_at(h: &mut Harness<'static, PdfCraftApp>, at: egui::Pos2) {
|
||||
h.hover_at(at);
|
||||
h.run_steps(2);
|
||||
h.drag_at(at);
|
||||
h.drop_at(at);
|
||||
h.run_steps(4);
|
||||
}
|
||||
|
||||
/// Click the link on page 1, at the middle of its rectangle.
|
||||
fn click_link(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen");
|
||||
let at = egui::pos2(r.min.x + r.width() * (150.0 / 300.0), r.min.y + r.height() * (1.0 - 325.0 / 400.0));
|
||||
click_at(h, at);
|
||||
}
|
||||
|
||||
fn click_field(h: &mut Harness<'static, PdfCraftApp>, name: &str) {
|
||||
let at = {
|
||||
let s = h.state();
|
||||
let doc = s.session.get(s.views[0].id).unwrap();
|
||||
let f = doc.form.iter().find(|f| f.name == name).unwrap();
|
||||
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen").center()
|
||||
};
|
||||
click_at(h, at);
|
||||
}
|
||||
|
||||
fn pending(url: &str, origin: LinkOrigin) -> Option<PendingLink> {
|
||||
Some(PendingLink { url: url.into(), origin })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_asks_first_and_opens_when_allowed() {
|
||||
let _gpu = gpu();
|
||||
let mut h = harness();
|
||||
click_link(&mut h);
|
||||
assert_eq!(h.state().pending_link, pending(LINK_URL, LinkOrigin::Link));
|
||||
assert_eq!(h.state().last_opened_url, None, "nothing opens before the user answers");
|
||||
h.get_by_label("example.org");
|
||||
h.get_by_label("Open link").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().last_opened_url.as_deref(), Some(LINK_URL));
|
||||
assert_eq!(h.state().pending_link, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_opens_nothing() {
|
||||
let _gpu = gpu();
|
||||
let mut h = harness();
|
||||
click_link(&mut h);
|
||||
assert!(h.state().pending_link.is_some());
|
||||
h.get_by_label("Cancel").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().pending_link, None);
|
||||
assert_eq!(h.state().last_opened_url, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_cancels() {
|
||||
let _gpu = gpu();
|
||||
let mut h = harness();
|
||||
click_link(&mut h);
|
||||
assert!(h.state().pending_link.is_some());
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().pending_link, None);
|
||||
assert_eq!(h.state().last_opened_url, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_button_uri_action_asks_first() {
|
||||
let _gpu = gpu();
|
||||
let mut h = harness();
|
||||
click_field(&mut h, "web");
|
||||
assert_eq!(h.state().pending_link, pending(BUTTON_URL, LinkOrigin::Button));
|
||||
assert_eq!(h.state().last_opened_url, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_button_script_cannot_open_a_local_program() {
|
||||
let _gpu = gpu();
|
||||
let mut h = harness();
|
||||
click_field(&mut h, "sneaky");
|
||||
assert_eq!(h.state().pending_link, None, "a file: address is never offered");
|
||||
assert_eq!(h.state().last_opened_url, None);
|
||||
let toast = h.state().toast.clone().expect("the user is told").0;
|
||||
assert!(toast.contains("“file:” address"), "{toast}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_asks_first_and_only_once() {
|
||||
let _gpu = gpu();
|
||||
let mut h = harness();
|
||||
let id = h.state().views[0].id;
|
||||
let script = "app.launchURL('https://example.org/?q=' + 'first'); app.launchURL('https://example.org/second');";
|
||||
h.state_mut().run_button_script(id, "web", script);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().pending_link, pending("https://example.org/?q=first", LinkOrigin::Script), "one dialog, for the first request");
|
||||
assert_eq!(h.state().last_opened_url, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_cannot_open_other_kinds_of_address() {
|
||||
let _gpu = gpu();
|
||||
for url in ["file:///etc/passwd", "javascript:alert(1)", "ms-settings:privacy", "smb://server/share", "https://exa\u{202E}gro.elpmaxe"] {
|
||||
let mut h = harness();
|
||||
let id = h.state().views[0].id;
|
||||
let script = format!("app.launchURL({});", serde_json::to_string(url).unwrap());
|
||||
h.state_mut().run_button_script(id, "web", &script);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().pending_link, None, "{url}");
|
||||
assert_eq!(h.state().last_opened_url, None, "{url}");
|
||||
assert!(h.state().toast.clone().is_some_and(|t| t.0.contains("won't open")), "{url}: {:?}", h.state().toast);
|
||||
}
|
||||
}
|
||||
@@ -2202,8 +2202,8 @@ milestone = "M7"
|
||||
status = "shipped"
|
||||
commands = ["edit.link"]
|
||||
tools = ["link_add"]
|
||||
evidence = ["crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed", "crates/automation/tests/automation.rs::links_through_tools"]
|
||||
notes = ""
|
||||
evidence = ["crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed", "crates/automation/tests/automation.rs::links_through_tools", "crates/ui-egui/tests/document_links.rs::a_link_asks_first_and_opens_when_allowed", "crates/ui-egui/tests/document_links.rs::a_button_uri_action_asks_first"]
|
||||
notes = "Opening a document's link or button URI asks first; only http, https and mailto addresses are offered (#90, #91)."
|
||||
|
||||
[[feature]]
|
||||
id = "edit.action-launch"
|
||||
@@ -5094,8 +5094,8 @@ milestone = "M6"
|
||||
status = "shipped"
|
||||
commands = ["app.preferences"]
|
||||
tools = ["js_enabled"]
|
||||
evidence = ["crates/js/src/tests.rs::the_sandbox_has_no_host_access", "crates/js/src/tests.rs::errors_and_runaway_scripts_are_contained", "crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences"]
|
||||
notes = "Preferences \u25b8 JavaScript \u25b8 Enable Acrobat JavaScript (remembered); no host access; loop and recursion limits."
|
||||
evidence = ["crates/js/src/tests.rs::the_sandbox_has_no_host_access", "crates/js/src/tests.rs::errors_and_runaway_scripts_are_contained", "crates/ui-egui/tests/javascript.rs::document_scripts_and_preferences", "crates/ui-egui/tests/document_links.rs::a_script_asks_first_and_only_once", "crates/ui-egui/tests/document_links.rs::a_script_cannot_open_other_kinds_of_address"]
|
||||
notes = "Preferences \u25b8 JavaScript \u25b8 Enable Acrobat JavaScript (remembered); no host access; loop and recursion limits; app.launchURL asks before opening and only offers http, https and mailto addresses (#90, #91)."
|
||||
|
||||
[[feature]]
|
||||
id = "form.folder-level-js"
|
||||
|
||||
Reference in New Issue
Block a user