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/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. diff --git a/src/printer/win_pdf.rs b/src/printer/win_pdf.rs index aa47e45..792296b 100644 --- a/src/printer/win_pdf.rs +++ b/src/printer/win_pdf.rs @@ -7,8 +7,10 @@ use std::path::Path; use thiserror::Error; use windows::core::HSTRING; use windows::Data::Pdf::PdfDocument; -use windows::Storage::StorageFile; +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; @@ -70,16 +72,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 +120,145 @@ impl WinPdfPrinter { } } +// 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 { + // 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()); + // 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() + .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 file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .attributes(2) + .open(&path) + .unwrap(); // FILE_ATTRIBUTE_HIDDEN + 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] + fn file_stream_reads_the_end_of_a_large_file_on_demand() { + 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())); + let file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .unwrap(); + let path = scopeguard::guard(path, |path| { + let _ = std::fs::remove_file(path); + }); + let size = 256 * 1024 * 1024; + { + 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(); + assert_eq!(stream.Size().unwrap(), size); + assert!(stream.CanRead().unwrap()); + assert!(!stream.CanWrite().unwrap()); + let input = stream.GetInputStreamAt(size - 1).unwrap(); + let buffer = Buffer::Create(1).unwrap(); + let read = input + .ReadAsync(&buffer, 1, InputStreamOptions::None) + .unwrap(); + 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(); + } + + #[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 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(error)) + if error.kind() == std::io::ErrorKind::InvalidInput + )); + } + + #[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;