From 17f5f1e3c5038be6bd122df36fc5b5aa2672502c Mon Sep 17 00:00:00 2001 From: Zhihui Song Date: Sat, 7 Nov 2026 14:12:05 +0800 Subject: [PATCH 1/5] fix: load Windows PDF print jobs through a stream --- src/printer/win_pdf.rs | 75 +++++++++++++++++++++++++++++++++++------- 1 file changed, 63 insertions(+), 12 deletions(-) diff --git a/src/printer/win_pdf.rs b/src/printer/win_pdf.rs index aa47e45..23d5ffc 100644 --- a/src/printer/win_pdf.rs +++ b/src/printer/win_pdf.rs @@ -3,11 +3,11 @@ use super::DxgiPrintContextError; use crate::printer::FilePrinter; use crate::printer::PrinterDevice; use crate::ticket::PrintTicket; +use std::io::Read as _; use std::path::Path; use thiserror::Error; -use windows::core::HSTRING; use windows::Data::Pdf::PdfDocument; -use windows::Storage::StorageFile; +use windows::Storage::Streams::{DataWriter, InMemoryRandomAccessStream}; use windows::Win32::Graphics::Direct2D::Common::D2D_SIZE_F; use windows::Win32::System::WinRT::Pdf::PdfCreateRenderer; use windows::Win32::System::WinRT::Pdf::PDF_RENDER_PARAMS; @@ -70,16 +70,7 @@ impl WinPdfPrinter { let print_control = &context.print_control; let d2d_context = &context.d2d_context; unsafe { - let absolute_path = - std::path::absolute(path).map_err(WinPdfPrinterError::InvalidPath)?; - let file = StorageFile::GetFileFromPathAsync(&HSTRING::from(absolute_path.as_path())) - .map_err(WinPdfPrinterError::FailedToOpenDocument)? - .join() - .map_err(WinPdfPrinterError::FailedToOpenDocument)?; - let pdf_document = PdfDocument::LoadFromFileAsync(&file) - .map_err(WinPdfPrinterError::FailedToOpenDocument)? - .join() - .map_err(WinPdfPrinterError::FailedToOpenDocument)?; + let pdf_document = load_document(path)?; let pdf_renderer = PdfCreateRenderer(dxgi_device).map_err(WinPdfPrinterError::RenderError)?; @@ -127,8 +118,68 @@ impl WinPdfPrinter { } } +// StorageFile path resolution can refuse otherwise readable desktop files (for example +// hidden temporary job files). Read with desktop file access and give Windows.Data.Pdf +// a stream instead. Transfer in chunks to avoid a second whole-file byte allocation. +fn load_document(path: &Path) -> Result { + let mut file = std::fs::File::open(path).map_err(WinPdfPrinterError::InvalidPath)?; + let stream = + InMemoryRandomAccessStream::new().map_err(WinPdfPrinterError::FailedToOpenDocument)?; + let writer = + DataWriter::CreateDataWriter(&stream).map_err(WinPdfPrinterError::FailedToOpenDocument)?; + let mut buffer = [0u8; 64 * 1024]; + loop { + let count = file + .read(&mut buffer) + .map_err(WinPdfPrinterError::InvalidPath)?; + if count == 0 { + break; + } + // Read guarantees count is within the supplied buffer. + writer + .WriteBytes(&buffer[..count]) + .map_err(WinPdfPrinterError::FailedToOpenDocument)?; + writer + .StoreAsync() + .map_err(WinPdfPrinterError::FailedToOpenDocument)? + .join() + .map_err(WinPdfPrinterError::FailedToOpenDocument)?; + } + writer + .DetachStream() + .map_err(WinPdfPrinterError::FailedToOpenDocument)?; + stream + .Seek(0) + .map_err(WinPdfPrinterError::FailedToOpenDocument)?; + PdfDocument::LoadFromStreamAsync(&stream) + .map_err(WinPdfPrinterError::FailedToOpenDocument)? + .join() + .map_err(WinPdfPrinterError::FailedToOpenDocument) +} + #[cfg(test)] mod tests { + #[test] + fn loads_a_hidden_job_file_without_storage_broker_path_access() { + use std::io::Write; + use std::os::windows::fs::OpenOptionsExt; + let _com = crate::utils::com::ComInitializer::new(); + let path = + std::env::temp_dir().join(format!("winprint-hidden-{}.pdf", uuid::Uuid::new_v4())); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .attributes(2) + .open(&path) + .unwrap(); // FILE_ATTRIBUTE_HIDDEN + file.write_all(include_bytes!("../../test_data/test_document.pdf")) + .unwrap(); + drop(file); + let result = super::load_document(&path); + std::fs::remove_file(&path).unwrap(); + assert!(result.unwrap().PageCount().unwrap() > 0); + } + use super::WinPdfPrinter; use crate::{printer::FilePrinter, test_utils::null_device}; use std::path::Path; From b334ebbbfe2ddfdc8b2deb2a7b372f0cf0a0d3e7 Mon Sep 17 00:00:00 2001 From: Zhihui Song Date: Sat, 7 Nov 2026 15:05:34 +0800 Subject: [PATCH 2/5] fix: read PDF jobs on demand through a read-only file stream --- src/printer/win_pdf.rs | 116 +++++++++++++++++++++++++++++------------ 1 file changed, 82 insertions(+), 34 deletions(-) diff --git a/src/printer/win_pdf.rs b/src/printer/win_pdf.rs index 23d5ffc..a7865cd 100644 --- a/src/printer/win_pdf.rs +++ b/src/printer/win_pdf.rs @@ -3,12 +3,14 @@ use super::DxgiPrintContextError; use crate::printer::FilePrinter; use crate::printer::PrinterDevice; use crate::ticket::PrintTicket; -use std::io::Read as _; use std::path::Path; use thiserror::Error; +use windows::core::HSTRING; use windows::Data::Pdf::PdfDocument; -use windows::Storage::Streams::{DataWriter, InMemoryRandomAccessStream}; +use windows::Storage::FileAccessMode; +use windows::Storage::Streams::IRandomAccessStream; use windows::Win32::Graphics::Direct2D::Common::D2D_SIZE_F; +use windows::Win32::System::WinRT::CreateRandomAccessStreamOnFile; use windows::Win32::System::WinRT::Pdf::PdfCreateRenderer; use windows::Win32::System::WinRT::Pdf::PDF_RENDER_PARAMS; @@ -118,39 +120,27 @@ impl WinPdfPrinter { } } -// StorageFile path resolution can refuse otherwise readable desktop files (for example -// hidden temporary job files). Read with desktop file access and give Windows.Data.Pdf -// a stream instead. Transfer in chunks to avoid a second whole-file byte allocation. -fn load_document(path: &Path) -> Result { - let mut file = std::fs::File::open(path).map_err(WinPdfPrinterError::InvalidPath)?; - let stream = - InMemoryRandomAccessStream::new().map_err(WinPdfPrinterError::FailedToOpenDocument)?; - let writer = - DataWriter::CreateDataWriter(&stream).map_err(WinPdfPrinterError::FailedToOpenDocument)?; - let mut buffer = [0u8; 64 * 1024]; - loop { - let count = file - .read(&mut buffer) - .map_err(WinPdfPrinterError::InvalidPath)?; - if count == 0 { - break; - } - // Read guarantees count is within the supplied buffer. - writer - .WriteBytes(&buffer[..count]) - .map_err(WinPdfPrinterError::FailedToOpenDocument)?; - writer - .StoreAsync() - .map_err(WinPdfPrinterError::FailedToOpenDocument)? - .join() - .map_err(WinPdfPrinterError::FailedToOpenDocument)?; +// StorageFile's broker can refuse otherwise readable desktop job files. Open a +// read-only, file-backed WinRT stream directly: no whole-file copy and no per-chunk +// DataWriter/StoreAsync operations. The PDF renderer reads the file on demand. +fn open_document_stream(path: &Path) -> Result { + let absolute_path = std::path::absolute(path).map_err(WinPdfPrinterError::InvalidPath)?; + let file_path = HSTRING::from(absolute_path.as_path()); + if file_path.contains(&0) { + return Err(WinPdfPrinterError::InvalidPath(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "the PDF path contains a NUL character", + ))); } - writer - .DetachStream() - .map_err(WinPdfPrinterError::FailedToOpenDocument)?; - stream - .Seek(0) - .map_err(WinPdfPrinterError::FailedToOpenDocument)?; + // SAFETY: HSTRING owns a NUL-terminated path for the duration of the call. + // The windows binding supplies IRandomAccessStream's IID and owns the returned + // COM reference. Read access does not create or modify the input file. + unsafe { CreateRandomAccessStreamOnFile(&file_path, FileAccessMode::Read.0 as u32) } + .map_err(WinPdfPrinterError::FailedToOpenDocument) +} + +fn load_document(path: &Path) -> Result { + let stream = open_document_stream(path)?; PdfDocument::LoadFromStreamAsync(&stream) .map_err(WinPdfPrinterError::FailedToOpenDocument)? .join() @@ -180,6 +170,64 @@ mod tests { assert!(result.unwrap().PageCount().unwrap() > 0); } + #[test] + fn file_stream_reads_the_end_of_a_large_file_on_demand() { + use windows::Storage::Streams::{DataReader, InputStreamOptions}; + let _com = crate::utils::com::ComInitializer::new(); + let path = + std::env::temp_dir().join(format!("winprint-large-{}.bin", uuid::Uuid::new_v4())); + let file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .unwrap(); + let size = 256 * 1024 * 1024; + file.set_len(size).unwrap(); + drop(file); + { + let stream = super::open_document_stream(&path).unwrap(); + assert_eq!(stream.Size().unwrap(), size); + assert!(stream.CanRead().unwrap()); + assert!(!stream.CanWrite().unwrap()); + let input = stream.GetInputStreamAt(size - 1).unwrap(); + let reader = DataReader::CreateDataReader(&input).unwrap(); + reader + .SetInputStreamOptions(InputStreamOptions::Partial) + .unwrap(); + assert_eq!(reader.LoadAsync(1).unwrap().join().unwrap(), 1); + assert_eq!(reader.ReadByte().unwrap(), 0); + } + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn a_missing_job_is_an_error_without_creating_a_file() { + let _com = crate::utils::com::ComInitializer::new(); + let path = + std::env::temp_dir().join(format!("winprint-missing-{}.pdf", uuid::Uuid::new_v4())); + assert!(super::load_document(&path).is_err()); + assert!(!path.exists()); + } + + #[test] + fn a_path_with_an_embedded_nul_is_rejected() { + let _com = crate::utils::com::ComInitializer::new(); + let path = std::path::Path::new("job.pdf\0ignored"); + assert!(matches!( + super::open_document_stream(path), + Err(super::WinPdfPrinterError::InvalidPath(_)) + )); + } + + #[test] + fn document_stream_is_read_only() { + let _com = crate::utils::com::ComInitializer::new(); + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("test_data/test_document.pdf"); + let stream = super::open_document_stream(&path).unwrap(); + assert!(stream.CanRead().unwrap()); + assert!(!stream.CanWrite().unwrap()); + } + use super::WinPdfPrinter; use crate::{printer::FilePrinter, test_utils::null_device}; use std::path::Path; From 03a4fb638734dc0f2b8e173de9e8dc895a8b1c75 Mon Sep 17 00:00:00 2001 From: Zhihui Song Date: Sun, 8 Nov 2026 00:05:46 +0800 Subject: [PATCH 3/5] fix: clarify path validation and clean up Windows stream tests safely --- Cargo.toml | 1 + src/printer/win_pdf.rs | 50 +++++++++++++++++++++++++++--------------- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f9257ce..1f16f07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ features = [ "Win32_System_Ole", "Win32_System_Threading", "Win32_System_Variant", + "Win32_System_WinRT", "Win32_System_WinRT_Pdf", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", diff --git a/src/printer/win_pdf.rs b/src/printer/win_pdf.rs index a7865cd..177b85e 100644 --- a/src/printer/win_pdf.rs +++ b/src/printer/win_pdf.rs @@ -124,14 +124,9 @@ impl WinPdfPrinter { // read-only, file-backed WinRT stream directly: no whole-file copy and no per-chunk // DataWriter/StoreAsync operations. The PDF renderer reads the file on demand. fn open_document_stream(path: &Path) -> Result { + // On Windows, absolute also rejects embedded NULs before any native stream call. let absolute_path = std::path::absolute(path).map_err(WinPdfPrinterError::InvalidPath)?; let file_path = HSTRING::from(absolute_path.as_path()); - if file_path.contains(&0) { - return Err(WinPdfPrinterError::InvalidPath(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "the PDF path contains a NUL character", - ))); - } // SAFETY: HSTRING owns a NUL-terminated path for the duration of the call. // The windows binding supplies IRandomAccessStream's IID and owns the returned // COM reference. Read access does not create or modify the input file. @@ -156,18 +151,31 @@ mod tests { let _com = crate::utils::com::ComInitializer::new(); let path = std::env::temp_dir().join(format!("winprint-hidden-{}.pdf", uuid::Uuid::new_v4())); - let mut file = std::fs::OpenOptions::new() + let file = std::fs::OpenOptions::new() .write(true) .create_new(true) .attributes(2) .open(&path) .unwrap(); // FILE_ATTRIBUTE_HIDDEN - file.write_all(include_bytes!("../../test_data/test_document.pdf")) - .unwrap(); - drop(file); - let result = super::load_document(&path); - std::fs::remove_file(&path).unwrap(); - assert!(result.unwrap().PageCount().unwrap() > 0); + let path = scopeguard::guard(path, |path| { + let _ = std::fs::remove_file(path); + }); + // Handles created below the guard are released before its cleanup on a panic. + { + let mut output = file; + output + .write_all(include_bytes!("../../test_data/test_document.pdf")) + .unwrap(); + } + { + let document = super::load_document(&path).unwrap(); + assert!(document.PageCount().unwrap() > 0); + let page = document.GetPage(0).unwrap(); + let size = page.Size().unwrap(); + assert!(size.Width > 0.0 && size.Height > 0.0); + } + // Verify normal cleanup only after the page and document have been released. + std::fs::remove_file(&*path).unwrap(); } #[test] @@ -181,9 +189,14 @@ mod tests { .create_new(true) .open(&path) .unwrap(); + let path = scopeguard::guard(path, |path| { + let _ = std::fs::remove_file(path); + }); let size = 256 * 1024 * 1024; - file.set_len(size).unwrap(); - drop(file); + { + let output = file; + output.set_len(size).unwrap(); + } { let stream = super::open_document_stream(&path).unwrap(); assert_eq!(stream.Size().unwrap(), size); @@ -197,7 +210,7 @@ mod tests { assert_eq!(reader.LoadAsync(1).unwrap().join().unwrap(), 1); assert_eq!(reader.ReadByte().unwrap(), 0); } - std::fs::remove_file(path).unwrap(); + std::fs::remove_file(&*path).unwrap(); } #[test] @@ -210,12 +223,13 @@ mod tests { } #[test] - fn a_path_with_an_embedded_nul_is_rejected() { + fn windows_path_normalization_rejects_embedded_nul() { let _com = crate::utils::com::ComInitializer::new(); let path = std::path::Path::new("job.pdf\0ignored"); assert!(matches!( super::open_document_stream(path), - Err(super::WinPdfPrinterError::InvalidPath(_)) + Err(super::WinPdfPrinterError::InvalidPath(error)) + if error.kind() == std::io::ErrorKind::InvalidInput )); } From 800a6f9fd738209e04b9c8a9e0bc2c552070da8a Mon Sep 17 00:00:00 2001 From: Zhihui Song Date: Sun, 8 Nov 2026 18:41:10 +0800 Subject: [PATCH 4/5] test: bound native tail reads instead of waiting indefinitely in DataReader --- src/printer/win_pdf.rs | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/printer/win_pdf.rs b/src/printer/win_pdf.rs index 177b85e..792296b 100644 --- a/src/printer/win_pdf.rs +++ b/src/printer/win_pdf.rs @@ -180,7 +180,9 @@ mod tests { #[test] fn file_stream_reads_the_end_of_a_large_file_on_demand() { - use windows::Storage::Streams::{DataReader, InputStreamOptions}; + use std::io::{Seek, SeekFrom, Write}; + use std::time::{Duration, Instant}; + use windows::Storage::Streams::{Buffer, DataReader, InputStreamOptions}; let _com = crate::utils::com::ComInitializer::new(); let path = std::env::temp_dir().join(format!("winprint-large-{}.bin", uuid::Uuid::new_v4())); @@ -194,8 +196,11 @@ mod tests { }); let size = 256 * 1024 * 1024; { - let output = file; + let mut output = file; output.set_len(size).unwrap(); + output.seek(SeekFrom::End(-1)).unwrap(); + output.write_all(&[0xa5]).unwrap(); + output.sync_all().unwrap(); } { let stream = super::open_document_stream(&path).unwrap(); @@ -203,12 +208,24 @@ mod tests { assert!(stream.CanRead().unwrap()); assert!(!stream.CanWrite().unwrap()); let input = stream.GetInputStreamAt(size - 1).unwrap(); - let reader = DataReader::CreateDataReader(&input).unwrap(); - reader - .SetInputStreamOptions(InputStreamOptions::Partial) + let buffer = Buffer::Create(1).unwrap(); + let read = input + .ReadAsync(&buffer, 1, InputStreamOptions::None) .unwrap(); - assert_eq!(reader.LoadAsync(1).unwrap().join().unwrap(), 1); - assert_eq!(reader.ReadByte().unwrap(), 0); + let started = Instant::now(); + // AsyncStatus::Started is 0. Poll the native operation rather than waiting + // indefinitely for a completion callback: this test previously hung in CI. + while read.Status().unwrap().0 == 0 { + if started.elapsed() >= Duration::from_secs(30) { + let _ = read.Cancel(); + panic!("the one-byte file-backed read did not complete within 30 seconds"); + } + std::thread::sleep(Duration::from_millis(10)); + } + let bytes = read.GetResults().unwrap(); + assert_eq!(bytes.Length().unwrap(), 1); + let reader = DataReader::FromBuffer(&bytes).unwrap(); + assert_eq!(reader.ReadByte().unwrap(), 0xa5); } std::fs::remove_file(&*path).unwrap(); } From 51934db382f225454cf152a8657eb03789be63c4 Mon Sep 17 00:00:00 2001 From: Zhihui Song Date: Sun, 8 Nov 2026 18:53:41 +0800 Subject: [PATCH 5/5] docs: record the PDF loading timeout and lifetime limitations --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 4947c4a..c85d93d 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,11 @@ The narrow changes used by craft-print are: An unsuccessful job requests cancellation; the status connection is unadvised before its COM apartment is released. The timeout does not bound calls into a printer driver or an interactive output-file dialog. +- PDF loading uses a read-only file-backed stream, but `LoadFromStreamAsync` is + currently joined without a deadline. The completion timeout above does not bound + document loading. Adding cancellation requires keeping the stream and temporary + file alive until the native operation has actually stopped; returning from a + timeout alone is not sufficient. This remains a known limitation of the loader. - `WinPdfPrinter::print_with_progress(path, ticket, callback)` reports `(processed, total)` after each successful Direct2D AddPage, preserving one job and its ticket. These counts measure page processing, never physical printing or terminal success.