diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index dcc8833a8..26737e158 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -927,6 +927,7 @@ pub(crate) fn with_export_preset(s: &Session, cmd: &str, settings: &mut Map Result { + check_passwords(p)?; let o = ScreenParams::of(p)?; let rows = rows_of(s, p, o.preset.as_deref())?; o.one_source()?; @@ -1280,18 +1281,46 @@ fn remember(s: &mut Session, p: &Value) { /// The PDF passwords an export may be given (`security` of a PDF row or of `settings.pdf`). const PASSWORD_KEYS: [&str; 2] = ["openPassword", "permissionsPassword"]; +/// Left where [`remove_passwords`] took a password out: an export of these settings is refused +/// until a password is given again ([`check_passwords`]), rather than written unprotected. +const PASSWORD_REQUIRED: &str = "passwordRequired"; + /// `settings` without the PDF passwords anywhere in them: settings kept in the document (which /// native saves write) or in the preferences hold the export's choices, never its secrets, as -/// the PDF settings themselves are saved without them. +/// the PDF settings themselves are saved without them. Where one was given, [`PASSWORD_REQUIRED`] +/// says so. fn remove_passwords(settings: &mut Map) { + let mut removed = false; for key in PASSWORD_KEYS { - settings.remove(key); + removed |= settings.remove(key).is_some_and(|v| v.as_str().is_none_or(|p| !p.is_empty())); + } + if removed { + settings.insert(PASSWORD_REQUIRED.into(), Value::Bool(true)); } for v in settings.values_mut() { remove_passwords_in(v); } } +/// Refuse export params `v` that hold settings whose PDF passwords were left out +/// ([`PASSWORD_REQUIRED`]) without a password given again. +fn check_passwords(v: &Value) -> Result<()> { + match v { + Value::Object(m) => { + let given = PASSWORD_KEYS.iter().any(|k| m.get(*k).and_then(Value::as_str).is_some_and(|p| !p.is_empty())); + if m.get(PASSWORD_REQUIRED) == Some(&Value::Bool(true)) && !given { + return Err(bad( + C, + "the PDF security settings were saved without their passwords: give openPassword or permissionsPassword again, or security without passwordRequired to export unprotected", + )); + } + m.values().try_for_each(check_passwords) + } + Value::Array(items) => items.iter().try_for_each(check_passwords), + _ => Ok(()), + } +} + fn remove_passwords_in(v: &mut Value) { match v { Value::Object(m) => remove_passwords(m), @@ -1376,5 +1405,6 @@ mod tests { assert!(!kept.to_string().contains("Password"), "{kept}"); assert_eq!(kept["formats"][0]["security"]["printing"], "low"); assert_eq!(kept["settings"]["pdf"]["security"]["copy"], false); + assert_eq!(kept["formats"][0]["security"]["passwordRequired"], true); } } diff --git a/crates/engine/src/tests/assets.rs b/crates/engine/src/tests/assets.rs index 3eca3ca43..8719873a9 100644 --- a/crates/engine/src/tests/assets.rs +++ b/crates/engine/src/tests/assets.rs @@ -454,3 +454,21 @@ fn collecting_explicit_assets_rejects_invalid_ids_without_adding_anything() { assert_eq!(run(&mut s, "assets.add", json!({"ids": [b, a]}))["added"], 2); assert_eq!(assets(&mut s).len(), 2); } + +/// Settings saved without the PDF passwords they were given export only once a password is given +/// again, never as an unprotected PDF; security given without `passwordRequired` exports as given. +#[test] +fn settings_saved_without_their_pdf_passwords_refuse_to_export_until_given_again() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + run(&mut s, "assets.add", json!({"ids": [a]})); + let row = |security: Value| json!({"formats": [{"format": "pdf", "security": security}]}); + run(&mut s, "assets.settings.set", row(json!({"openPassword": "open sesame"}))); + let err = s.execute("assets.export", &json!({})).unwrap_err().to_string(); + assert!(err.contains("without their passwords"), "{err}"); + let encrypted = |r: &Value| files(r)[0].1.windows(8).any(|w| w == b"/Encrypt"); + let r = run(&mut s, "assets.export", row(json!({"openPassword": "open sesame", "passwordRequired": true}))); + assert!(encrypted(&r), "given again: protected"); + let r = run(&mut s, "assets.export", row(json!({}))); + assert!(!encrypted(&r), "left out on purpose: unprotected"); +}