panic_hunt: don't fuzz the print commands into a real printer (#4467)

* panic_hunt: don't fuzz the print commands into a real printer

file.print and file.printOneCopy spool to CUPS, so every run of the adversarial-params test
printed a blank page on the default printer. The hunt now skips those two ids (their params
are validated like everyone else's; spooling is not what the test is for).

* Guard the print spooler itself and the CI environment

- print_cmds: disable_printing() (a process-wide flag the panic hunt sets) and the
  PHOTOCRAFT_NO_PRINT environment variable make spool_pdf refuse before running lp; dryRun
  still renders and reports the command. Test.
- CI: PHOTOCRAFT_NO_PRINT=1 and LPDEST=photocraft-no-such-printer, so a regression fails
  loudly instead of printing. Documented in docs/development.md › Environment variables.
This commit is contained in:
Fabio Lissi authored and GitHub committed 2026-10-12 23:13:42 -04:00
1 parent 15aac8d7b6
commit 70e182b08f
5 files changed
+51 -1

No files matched your search

+4
View File
@@ -17,6 +17,10 @@ on:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# No test or fuzz run may print a page: the engine refuses to spool, and if something ever
# bypasses that, lp fails loudly on a printer that does not exist.
PHOTOCRAFT_NO_PRINT: "1"
LPDEST: photocraft-no-such-printer
# Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the
# debug info: faster codegen and links (Windows PDBs most of all), and a target dir small enough
# for the Linux job's second, craft-fonts build (it ran out of disk with full debug info).
+21
View File
@@ -409,6 +409,13 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result<Value> {
let mut sent = false;
let mut spool = Value::Null;
if send && !dry {
// The last line of defence: a test or fuzz run must never print a page (`dryRun` still
// renders and reports the command).
if printing_disabled() {
return Err(other(
"printing is disabled in this process (photocraft_engine::print_cmds::disable_printing or PHOTOCRAFT_NO_PRINT); use dryRun: true",
));
}
spool = json!(craft_print::submit(&pdf, &job).map_err(other)?);
sent = true;
}
@@ -429,6 +436,20 @@ fn remember_print(s: &mut Session, p: &Value) {
s.file_menu.last_print = Some(remembered);
}
/// Set once by test harnesses (the panic hunt) so no command in the process can reach a printer.
static PRINTING_DISABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Refuse to spool anything from this process from now on (tests, fuzzing). The
/// `PHOTOCRAFT_NO_PRINT` environment variable does the same for a whole shell (CI).
pub fn disable_printing() {
PRINTING_DISABLED.store(true, std::sync::atomic::Ordering::Relaxed);
}
/// Whether spooling is blocked by [`disable_printing`] or `PHOTOCRAFT_NO_PRINT`.
pub fn printing_disabled() -> bool {
PRINTING_DISABLED.load(std::sync::atomic::Ordering::Relaxed) || std::env::var_os("PHOTOCRAFT_NO_PRINT").is_some_and(|v| !v.is_empty() && v != "0")
}
fn print(s: &mut Session, p: &Value) -> Result<Value> {
do_print(s, p, "file.print")
}
+18
View File
@@ -345,8 +345,26 @@ fn spool_transport_streams_pdf_and_propagates_refusal() {
.args(["--exact", "print_cmds::tests::spool_transport_streams_pdf_and_propagates_refusal", "--nocapture"])
.env(CHILD, dir.path())
.env("PATH", dir.path())
// The child spools into the fake `lp` above, so a shell that blocks real printing
// (`PHOTOCRAFT_NO_PRINT`, see `disabled_printing_never_spools`) must not stop it.
.env_remove("PHOTOCRAFT_NO_PRINT")
.output()
.unwrap();
assert!(result.status.success(), "{}\n{}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr));
assert!(String::from_utf8_lossy(&result.stdout).contains("1 passed"), "child must select the transport test");
}
#[test]
fn disabled_printing_never_spools() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
super::disable_printing();
assert!(super::printing_disabled());
let e = s.execute("file.print", json!({})).unwrap_err().to_string();
assert!(e.contains("printing is disabled"), "{e}");
assert!(s.execute("file.printOneCopy", json!({})).is_err());
// A dry run still renders and reports the command it would have run.
let r = s.execute("file.print", json!({"dryRun": true})).unwrap();
assert_eq!(r["sent"], false);
assert!(r["command"].is_array(), "{r}");
}
+7 -1
View File
@@ -9,6 +9,8 @@ use std::sync::mpsc;
use std::time::Duration;
fn fresh() -> Session {
// Belt and braces with the EXTERNAL skip below: nothing in this process may reach a printer.
photocraft_engine::print_cmds::disable_printing();
let mut s = Session::new();
s.execute("file.new", json!({"width": 24, "height": 16})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
@@ -58,8 +60,12 @@ fn no_command_panics_or_hangs_on_adversarial_params() {
json!({"x": 1e5, "y": -1e5, "radius": 300, "amount": 300, "angle": 1e5, "opacity": -100, "scale": -5, "gamma": -1}),
json!({"points": [], "from": [0, 0], "to": [0, 0], "stops": [], "matrix": [0, 0, 0, 0, 0, 0], "colors": [], "name": "", "mode": "", "style": ""}),
];
// Commands whose success reaches outside the process: `file.print` and `file.printOneCopy`
// spool to CUPS, so a fuzz run with `{}` printed a blank page on the default printer. Their
// params are validated like everyone else's; the spooling itself is not fuzzed.
const EXTERNAL: &[&str] = &["file.print", "file.printOneCopy"];
let mut bad: Vec<String> = Vec::new();
for spec in command_specs() {
for spec in command_specs().iter().filter(|spec| !EXTERNAL.contains(&spec.id)) {
for p in &params {
if let Err(why) = run_guarded(spec.id, p) {
bad.push(format!("{} [{why}] <- {p}", spec.id));
+1
View File
@@ -104,6 +104,7 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`.
| Variable | Effect |
|---|---|
| `PHOTOCRAFT_CONTROL_PORT` | Same as `--control <port>` |
| `PHOTOCRAFT_NO_PRINT` | Set to anything but `0`: `file.print` / `file.printOneCopy` refuse to spool (`dryRun` still works). CI sets it, with `LPDEST=photocraft-no-such-printer` as a second net; set both when running the panic hunt or fuzzers locally |
| `RUST_LOG` | Log levels for standard error and the log file (see [Logs](#logs)) |
| `PHOTOCRAFT_CONTROL_TOKEN` | 64-hex bearer token for control TCP (avoid on shared systems where environment inspection is possible) |
| `PHOTOCRAFT_CONTROL_TOKEN_FILE` | Read, or create for a server, the control bearer-token file |