winprint_ext/test_utils/
file_device.rs1use crate::printer::PrinterDevice;
2use sha2::{Digest, Sha256};
3use std::{
4 collections::HashSet,
5 io,
6 marker::PhantomData,
7 path::{Path, PathBuf},
8 process::{Command, Stdio},
9 sync::{Mutex, OnceLock},
10};
11use uuid::Uuid;
12
13pub trait FilePrinterProvider {
18 fn driver_name() -> &'static str;
21}
22
23pub struct PwgRaster;
25impl FilePrinterProvider for PwgRaster {
26 fn driver_name() -> &'static str {
27 "Microsoft PWG Raster Class Driver"
28 }
29}
30
31pub struct Pdf;
33impl FilePrinterProvider for Pdf {
34 fn driver_name() -> &'static str {
35 "Microsoft Print To PDF"
36 }
37}
38
39fn ps_quote(s: &str) -> String {
40 format!("'{}'", s.replace('\'', "''"))
42}
43
44fn run_powershell(context: &str, script: &str) -> io::Result<()> {
48 let status = Command::new("powershell")
49 .stdin(Stdio::null())
50 .stdout(Stdio::inherit())
51 .stderr(Stdio::inherit())
52 .args(["-NoProfile", "-Command", script])
53 .status()?;
54 if !status.success() {
55 return Err(io::Error::new(
56 io::ErrorKind::Other,
57 format!("powershell failed during {context}: exit status {status:?}"),
58 ));
59 }
60 Ok(())
61}
62
63fn printer_name_for(port_path: &str) -> String {
64 let hash = Sha256::digest(port_path.as_bytes());
66 format!(
67 "file-device-{}-{}",
68 std::process::id(),
69 bs58::encode(hash).into_string()
70 )
71}
72
73fn make_temp_port_path() -> io::Result<PathBuf> {
74 let uuid = Uuid::new_v4();
75 let mut p = std::env::temp_dir().canonicalize()?;
76 p.push(format!(
77 "winprint-file-device-{}.prn",
78 uuid.as_simple().to_string()
79 ));
80 Ok(p)
81}
82
83fn ensure_driver_and_cleanup(driver: &'static str) -> io::Result<()> {
86 static INIT: OnceLock<Mutex<HashSet<&'static str>>> = OnceLock::new();
87 let map = INIT.get_or_init(|| Mutex::new(HashSet::new()));
88 let mut guard = map.lock().expect("INIT mutex poisoned");
89 if guard.contains(driver) {
90 return Ok(());
91 }
92
93 let script = format!(
103 r#"
104$ErrorActionPreference = 'Stop'
105Set-StrictMode -Version 2
106
107# 1. Install the driver if missing.
108if (-not (Get-PrinterDriver -Name {driver_q} -ErrorAction SilentlyContinue)) {{
109 Add-PrinterDriver -Name {driver_q}
110}}
111
112# 2. Scan existing printers and remove leftovers.
113# Minimal base58 decoder (Bitcoin alphabet) adapted from
114# https://gist.github.com/gkostoulias/9e0af1595aaf5e6728443497a7defbe5
115function Convert-FromBase58 {{
116 param([string]$s)
117 $alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
118 $num = [System.Numerics.BigInteger]::Zero
119 foreach ($c in $s.ToCharArray()) {{
120 $i = $alphabet.IndexOf($c)
121 if ($i -lt 0) {{ return $null }}
122 $num = ($num * [System.Numerics.BigInteger]58) + [System.Numerics.BigInteger]$i
123 }}
124 $bytes = $num.ToByteArray()
125 # BigInteger is little-endian; strip the trailing sign byte (if present) and reverse.
126 if ($bytes.Length -gt 1 -and $bytes[$bytes.Length - 1] -eq 0) {{
127 $bytes = $bytes[0..($bytes.Length - 2)]
128 }}
129 [array]::Reverse($bytes)
130 # Add leading zero bytes for each leading '1' in the input.
131 $leading = 0
132 foreach ($c in $s.ToCharArray()) {{
133 if ($c -eq '1') {{ $leading++ }} else {{ break }}
134 }}
135 if ($leading -gt 0) {{
136 $pad = New-Object byte[] $leading
137 $bytes = $pad + $bytes
138 }}
139 return ,$bytes
140}}
141
142$sha256 = [System.Security.Cryptography.SHA256]::Create()
143
144$printers = Get-Printer | Where-Object {{
145 $_.Type -eq 'Local' -and $_.DriverName -eq {driver_q} -and $_.Name -match '^file-device-(\d+)-([1-9A-HJ-NP-Za-km-z]+)$'
146}}
147
148foreach ($p in $printers) {{
149 $m = [regex]::Match($p.Name, '^file-device-(\d+)-([1-9A-HJ-NP-Za-km-z]+)$')
150 $pidValue = [int]$m.Groups[1].Value
151 $hashB58 = $m.Groups[2].Value
152
153 # If the owning process is still alive, leave it alone.
154 $alive = $true
155 try {{ $null = Get-Process -Id $pidValue -ErrorAction Stop }} catch {{ $alive = $false }}
156 if ($alive) {{ continue }}
157
158 # Verify bs58(sha256(port)) matches the printer name so that we only remove printers we
159 # created. Otherwise: skip and keep going.
160 $portName = $p.PortName
161 if (-not $portName) {{ continue }}
162 $expected = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($portName))
163 $actual = Convert-FromBase58 $hashB58
164 if ($null -eq $actual -or $actual.Length -ne $expected.Length) {{ continue }}
165 $eq = $true
166 for ($i = 0; $i -lt $expected.Length; $i++) {{
167 if ($actual[$i] -ne $expected[$i]) {{ $eq = $false; break }}
168 }}
169 if (-not $eq) {{ continue }}
170
171 # Expected-to-possibly-fail operations (racing cleanups, transient spooler state, ...):
172 # wrap in try/catch so a single stale entry doesn't abort the whole sweep.
173 try {{ Remove-Printer -InputObject $p }}
174 catch {{ Write-Warning ("Remove-Printer {{0}} failed: {{1}}" -f $p.Name, $_.Exception.Message) }}
175
176 if ($port = Get-PrinterPort -Name $portName -ErrorAction SilentlyContinue) {{
177 try {{ Remove-PrinterPort -InputObject $port }}
178 catch {{ Write-Warning ("Remove-PrinterPort {{0}} failed: {{1}}" -f $portName, $_.Exception.Message) }}
179 }}
180 if (Test-Path -LiteralPath $portName) {{
181 try {{ Remove-Item -LiteralPath $portName -Force }}
182 catch {{ Write-Warning ("Remove-Item {{0}} failed: {{1}}" -f $portName, $_.Exception.Message) }}
183 }}
184}}
185"#,
186 driver_q = ps_quote(driver),
187 );
188 run_powershell("driver install + leftover cleanup", &script)?;
189 guard.insert(driver);
190 Ok(())
191}
192
193pub struct FilePrinterDevice<T: FilePrinterProvider> {
209 device: PrinterDevice,
210 port_path: PathBuf,
211 _phantom: PhantomData<fn() -> T>,
212}
213
214impl<T: FilePrinterProvider> FilePrinterDevice<T> {
215 pub fn new() -> io::Result<Self> {
221 let driver = T::driver_name();
222 ensure_driver_and_cleanup(driver)?;
223
224 let port_path = make_temp_port_path()?;
225 let port_str = port_path
226 .to_str()
227 .ok_or(io::Error::other("contains non-Unicode chars in path"))?;
228 let printer_name = printer_name_for(&port_str);
229
230 let script = format!(
231 r#"
232$ErrorActionPreference = 'Stop'
233Set-StrictMode -Version 2
234if (-not (Get-PrinterPort -Name {port_q} -ErrorAction SilentlyContinue)) {{
235 Add-PrinterPort -Name {port_q}
236}}
237Add-Printer -Name {name_q} -PortName {port_q} -DriverName {driver_q}
238"#,
239 port_q = ps_quote(&port_str),
240 name_q = ps_quote(&printer_name),
241 driver_q = ps_quote(driver),
242 );
243 run_powershell("add printer", &script)?;
244
245 let device = PrinterDevice::all()
246 .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("enumerate printers: {e:?}")))?
247 .into_iter()
248 .find(|p| p.name() == printer_name)
249 .ok_or_else(|| {
250 io::Error::new(
251 io::ErrorKind::NotFound,
252 format!(
253 "virtual printer '{printer_name}' not found after creation (port='{port_str}', driver='{driver}')"
254 ),
255 )
256 })?;
257
258 Ok(FilePrinterDevice {
259 device,
260 port_path,
261 _phantom: PhantomData,
262 })
263 }
264
265 pub fn device(&self) -> &PrinterDevice {
267 &self.device
268 }
269
270 pub fn file_path(&self) -> &Path {
272 &self.port_path
273 }
274}
275
276impl<T: FilePrinterProvider> Drop for FilePrinterDevice<T> {
277 fn drop(&mut self) {
278 let port_str = self.port_path.to_string_lossy().into_owned();
279 let script = format!(
280 r#"
281$ErrorActionPreference = 'Stop'
282Set-StrictMode -Version 2
283if ($p = Get-Printer -Name {name_q} -ErrorAction SilentlyContinue) {{
284 try {{ Remove-Printer -InputObject $p }}
285 catch {{ Write-Warning ("Remove-Printer {{0}} failed: {{1}}" -f {name_q}, $_.Exception.Message) }}
286}}
287if ($port = Get-PrinterPort -Name {port_q} -ErrorAction SilentlyContinue) {{
288 try {{ Remove-PrinterPort -InputObject $port }}
289 catch {{ Write-Warning ("Remove-PrinterPort failed: {{0}}" -f $_.Exception.Message) }}
290}}
291"#,
292 name_q = ps_quote(self.device.name()),
293 port_q = ps_quote(&port_str),
294 );
295 let _ = run_powershell("remove printer", &script);
297 let _ = std::fs::remove_file(&self.port_path);
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use crate::printer::PrinterDevice;
305
306 fn printer_exists(name: &str) -> bool {
307 PrinterDevice::all()
308 .unwrap()
309 .into_iter()
310 .any(|p| p.name() == name)
311 }
312
313 #[test]
314 fn pwg_raster_device_lifetime() {
315 let name;
316 let path;
317 {
318 let dev = FilePrinterDevice::<PwgRaster>::new().unwrap();
319 name = dev.device().name().to_string();
320 path = dev.file_path().to_path_buf();
321 assert!(name.starts_with("file-device-"), "unexpected name: {name}");
322 assert!(printer_exists(&name), "printer {name} was not created");
323 }
324 assert!(
325 !printer_exists(&name),
326 "printer {name} still present after drop"
327 );
328 assert!(
329 !path.exists(),
330 "backing file {} still present after drop",
331 path.display()
332 );
333 }
334
335 #[test]
336 fn pdf_device_lifetime() {
337 let name;
338 {
339 let dev = FilePrinterDevice::<Pdf>::new().unwrap();
340 name = dev.device().name().to_string();
341 assert!(printer_exists(&name), "printer {name} was not created");
342 }
343 assert!(
344 !printer_exists(&name),
345 "printer {name} still present after drop"
346 );
347 }
348
349 #[test]
350 fn printer_name_is_stable_for_same_port() {
351 let n1 = printer_name_for(r"C:\Temp\foo.prn");
352 let n2 = printer_name_for(r"C:\Temp\foo.prn");
353 assert_eq!(n1, n2);
354 let n3 = printer_name_for(r"C:\Temp\bar.prn");
355 assert_ne!(n1, n3);
356 }
357
358 #[test]
359 fn two_devices_coexist() {
360 let a = FilePrinterDevice::<PwgRaster>::new().unwrap();
361 let b = FilePrinterDevice::<PwgRaster>::new().unwrap();
362 assert_ne!(a.device().name(), b.device().name());
363 assert_ne!(a.file_path(), b.file_path());
364 assert!(printer_exists(a.device().name()));
365 assert!(printer_exists(b.device().name()));
366 }
367}
368