Skip to main content

winprint_ext/test_utils/
file_device.rs

1use 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
13/// Defines a virtual "print-to-file" device backed by a built-in Windows driver.
14///
15/// Implement this trait to expose additional file-based drivers. Two built-in providers are
16/// shipped: [`PwgRaster`] and [`Pdf`].
17pub trait FilePrinterProvider {
18    /// Returns the Windows driver name (as accepted by `Add-Printer -DriverName`) to use for the
19    /// virtual printer.
20    fn driver_name() -> &'static str;
21}
22
23/// A built-in [`FilePrinterProvider`] backed by the `Microsoft PWG Raster Class Driver`.
24pub struct PwgRaster;
25impl FilePrinterProvider for PwgRaster {
26    fn driver_name() -> &'static str {
27        "Microsoft PWG Raster Class Driver"
28    }
29}
30
31/// A built-in [`FilePrinterProvider`] backed by the `Microsoft Print To PDF` driver.
32pub 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    // PowerShell single-quoted string: escape ' by doubling it.
41    format!("'{}'", s.replace('\'', "''"))
42}
43
44/// Run a PowerShell script and fail with `io::Error` if PowerShell exits with a non-zero status.
45///
46/// `context` is prepended to the resulting error message for easier diagnosis.
47fn 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    // printer name = file-device-{pid}-{bs58(sha256(port_path))}
65    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
83/// Run the one-shot init script: install the driver and sweep away any leftover
84/// `file-device-*` printers owned by dead pids. Runs at most once per (process, driver).
85fn 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    // NOTE: keep this script compatible with Windows PowerShell 5.1.
94    //
95    // Error-handling policy:
96    //   * `$ErrorActionPreference = 'Stop'` by default ? any unexpected failure aborts the
97    //     script with a non-zero exit code.
98    //   * Operations where "failure" is a legitimate expected outcome (e.g. "the printer I
99    //     just removed is already gone because a sibling process also cleaned it") are wrapped
100    //     in `try { ... } catch { Write-Warning ... }` so they are visibly reported but do
101    //     not abort the whole sweep.
102    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
193/// A virtual "print-to-file" printer device backed by a built-in Windows driver.
194///
195/// Each instance installs a freshly-created printer port (a unique temp file) and a printer
196/// using the driver supplied by `T`. Dropping the value removes the printer, the port, and the
197/// backing temp file. Unlike `null_device`, this type is **not** thread-local or shared ? each
198/// `FilePrinterDevice` owns its own printer; if you need several, construct several.
199///
200/// ```no_run
201/// use winprint_ext::test_utils::file_device::{FilePrinterDevice, PwgRaster};
202///
203/// let dev = FilePrinterDevice::<PwgRaster>::new().unwrap();
204/// println!("printer: {}", dev.device().name());
205/// println!("output:  {}", dev.file_path().display());
206/// // ... use dev.device() for printing ...
207/// ```
208pub struct FilePrinterDevice<T: FilePrinterProvider> {
209    device: PrinterDevice,
210    port_path: PathBuf,
211    _phantom: PhantomData<fn() -> T>,
212}
213
214impl<T: FilePrinterProvider> FilePrinterDevice<T> {
215    /// Create a new virtual "print-to-file" printer.
216    ///
217    /// On first call per-process (per driver) this installs the driver and cleans up
218    /// leftover printers from previous crashed runs. Subsequent calls only create the port
219    /// and printer.
220    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    /// Returns the underlying [`PrinterDevice`].
266    pub fn device(&self) -> &PrinterDevice {
267        &self.device
268    }
269
270    /// Returns the path to the backing file that the driver spools its output into.
271    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        // Drop cannot propagate errors.
296        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