Admin 2633736fb5
Some checks failed
Desktop Release / Bootstrap updater signing key (push) Has been cancelled
Desktop Release / Windows x64 NSIS (push) Has been cancelled
Desktop Release / Linux x64 DEB + AppImage (push) Has been cancelled
Desktop Release / Publish public desktop release (push) Has been cancelled
fix(linux): read protected CUPS PPD via PolicyKit
2026-09-24 04:22:02 +00:00

672 lines
21 KiB
Rust

use std::process::Command;
#[cfg(any(target_os = "windows", target_os = "linux"))]
use std::fs;
#[cfg(target_os = "linux")]
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
#[cfg(target_os = "windows")]
use serde_json::Value;
pub const VIRTUAL_PRINTER_NAME: &str = "FieldLens Print";
#[cfg(target_os = "linux")]
pub const TEST_VIRTUAL_PRINTER_NAME: &str = "FieldLens Test Capture";
#[cfg(target_os = "linux")]
const TEST_VIRTUAL_PRINTER_QUEUE: &str = "FieldLens-Test-Capture";
#[cfg(target_os = "linux")]
const TEST_CAPTURE_DEVICE_URI: &str = "socket://127.0.0.1:9101";
const WINDOWS_PORT_NAME: &str = "FieldLensPrint-9100";
#[cfg(target_os = "linux")]
const FIELDLENS_CUPS_SPOOL_DIR: &str = "/var/spool/fieldlens-print";
#[cfg(target_os = "linux")]
const FIELDLENS_CUPS_FILTER: &str = r#"#!/usr/bin/python3
import os
import re
import sys
import uuid
SPOOL = "/var/spool/fieldlens-print"
def safe(value):
value = re.sub(r"[^A-Za-z0-9._-]+", "-", value or "job").strip("-")
return value[:80] or "job"
job_id = safe(sys.argv[1] if len(sys.argv) > 1 else "job")
title = safe(sys.argv[3] if len(sys.argv) > 3 else "print")
printer = safe(os.environ.get("PRINTER", "printer"))
content_type = os.environ.get("CONTENT_TYPE", "application/pdf")
ext = {
"application/pdf": "pdf",
"application/postscript": "ps",
"image/png": "png",
"image/jpeg": "jpg",
}.get(content_type, "dat")
prefix = "test-" if printer.lower() == "fieldlens-test-capture" else "print-"
name = f"{prefix}{printer}-{job_id}-{title}-{uuid.uuid4().hex}.{ext}"
final_path = os.path.join(SPOOL, name)
part_path = final_path + ".part"
source = None
capture = None
captured_ok = False
try:
if len(sys.argv) > 6 and sys.argv[6]:
source = open(sys.argv[6], "rb")
else:
source = sys.stdin.buffer
try:
capture = open(part_path, "wb")
except OSError:
capture = None
while True:
chunk = source.read(65536)
if not chunk:
break
if capture is not None:
try:
capture.write(chunk)
except OSError:
try:
capture.close()
except OSError:
pass
capture = None
sys.stdout.buffer.write(chunk)
sys.stdout.buffer.flush()
if capture is not None:
capture.flush()
capture.close()
capture = None
os.chmod(part_path, 0o644)
os.replace(part_path, final_path)
captured_ok = True
finally:
if source is not None and source is not sys.stdin.buffer:
source.close()
if capture is not None:
capture.close()
if not captured_ok:
try:
os.unlink(part_path)
except OSError:
pass
"#;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SystemPrinter {
pub name: String,
pub driver: String,
pub port: String,
pub address: Option<String>,
pub is_virtual: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VirtualPrinterStatus {
pub installed: bool,
pub name: String,
pub detail: String,
}
fn is_virtual_printer_name(name: &str) -> bool {
if name.eq_ignore_ascii_case(VIRTUAL_PRINTER_NAME)
|| name.eq_ignore_ascii_case("FieldLens-Print")
{
return true;
}
#[cfg(target_os = "linux")]
{
return name.eq_ignore_ascii_case(TEST_VIRTUAL_PRINTER_NAME)
|| name.eq_ignore_ascii_case(TEST_VIRTUAL_PRINTER_QUEUE);
}
#[cfg(not(target_os = "linux"))]
false
}
pub fn list_system_printers() -> Result<Vec<SystemPrinter>> {
#[cfg(target_os = "windows")]
{
return list_windows_printers();
}
#[cfg(target_os = "linux")]
{
return list_linux_printers();
}
#[allow(unreachable_code)]
Ok(Vec::new())
}
pub fn virtual_printer_status() -> Result<VirtualPrinterStatus> {
#[cfg(target_os = "linux")]
{
return linux_virtual_printer_status();
}
let installed = list_system_printers()?
.iter()
.any(|printer| is_virtual_printer_name(&printer.name));
Ok(VirtualPrinterStatus {
installed,
name: VIRTUAL_PRINTER_NAME.into(),
detail: if installed {
"Ready in the system print dialog. Keep RAW capture running while printing.".into()
} else {
"Not installed yet. Choose the physical destination printer, then create it.".into()
},
})
}
pub fn install_virtual_printer(target_printer: &str) -> Result<VirtualPrinterStatus> {
let target_printer = target_printer.trim();
if target_printer.is_empty() {
return Err(anyhow!("choose the physical printer to forward to first"));
}
if is_virtual_printer_name(target_printer) {
return Err(anyhow!("FieldLens Print cannot forward to itself"));
}
#[cfg(target_os = "windows")]
install_windows_virtual_printer(target_printer)?;
#[cfg(target_os = "linux")]
install_linux_virtual_printer(target_printer)?;
virtual_printer_status()
}
pub fn remove_virtual_printer() -> Result<VirtualPrinterStatus> {
#[cfg(target_os = "windows")]
remove_windows_virtual_printer()?;
#[cfg(target_os = "linux")]
remove_linux_virtual_printer()?;
virtual_printer_status()
}
#[cfg(target_os = "windows")]
fn list_windows_printers() -> Result<Vec<SystemPrinter>> {
let script = r#"
$items = @(
Get-Printer | ForEach-Object {
$printer = $_
$port = Get-PrinterPort -Name $printer.PortName -ErrorAction SilentlyContinue
$address = $null
if ($null -ne $port -and $port.PrinterHostAddress) {
$number = if ($port.PortNumber) { $port.PortNumber } else { 9100 }
$address = "$($port.PrinterHostAddress):$number"
}
[pscustomobject]@{
name = $printer.Name
driver = $printer.DriverName
port = $printer.PortName
address = $address
isVirtual = ($printer.Name -eq 'FieldLens Print')
}
}
)
$items | ConvertTo-Json -Compress
"#;
let output = Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", script])
.output()
.context("query Windows printers")?;
if !output.status.success() {
return Err(anyhow!(
"Windows printer query failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let value: Value = serde_json::from_str(stdout.trim()).context("decode Windows printer list")?;
let values = match value {
Value::Array(values) => values,
Value::Null => vec![],
value => vec![value],
};
let mut printers = Vec::new();
for value in values {
let printer: SystemPrinter = serde_json::from_value(value)?;
printers.push(printer);
}
printers.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
Ok(printers)
}
#[cfg(target_os = "windows")]
fn install_windows_virtual_printer(target_printer: &str) -> Result<()> {
let target = ps_quote(target_printer);
let script_path = std::env::temp_dir().join(format!(
"fieldlens-printer-setup-{}.ps1",
uuid::Uuid::new_v4()
));
let script = format!(
r#"param([string]$TargetPrinter)
$ErrorActionPreference = 'Stop'
$target = Get-Printer -Name $TargetPrinter -ErrorAction Stop
$portName = '{port}'
$queueName = '{queue}'
if (-not (Get-PrinterPort -Name $portName -ErrorAction SilentlyContinue)) {{
Add-PrinterPort -Name $portName -PrinterHostAddress '127.0.0.1' -PortNumber 9100
}}
$existing = Get-Printer -Name $queueName -ErrorAction SilentlyContinue
if ($existing) {{
Set-Printer -Name $queueName -DriverName $target.DriverName -PortName $portName
}} else {{
Add-Printer -Name $queueName -DriverName $target.DriverName -PortName $portName
}}
"#,
port = WINDOWS_PORT_NAME,
queue = VIRTUAL_PRINTER_NAME,
);
fs::write(&script_path, script).context("write Windows printer setup script")?;
let path = ps_quote(&script_path.to_string_lossy());
let elevated = format!(
"$p = Start-Process powershell -Verb RunAs -Wait -PassThru -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{path}','-TargetPrinter','{target}'); exit $p.ExitCode"
);
let status = Command::new("powershell")
.args(["-NoProfile", "-Command", &elevated])
.status()
.context("launch elevated Windows printer setup")?;
let _ = fs::remove_file(&script_path);
if !status.success() {
return Err(anyhow!(
"printer setup was cancelled or failed; Windows administrator approval may be required"
));
}
Ok(())
}
#[cfg(target_os = "windows")]
fn remove_windows_virtual_printer() -> Result<()> {
let script = format!(
r#"$ErrorActionPreference = 'Stop'
$queueName = '{queue}'
$portName = '{port}'
if (Get-Printer -Name $queueName -ErrorAction SilentlyContinue) {{
Remove-Printer -Name $queueName
}}
if (Get-PrinterPort -Name $portName -ErrorAction SilentlyContinue) {{
Remove-PrinterPort -Name $portName
}}
"#,
queue = VIRTUAL_PRINTER_NAME,
port = WINDOWS_PORT_NAME,
);
let script_path = std::env::temp_dir().join(format!(
"fieldlens-printer-remove-{}.ps1",
uuid::Uuid::new_v4()
));
fs::write(&script_path, script)?;
let path = ps_quote(&script_path.to_string_lossy());
let elevated = format!(
"$p = Start-Process powershell -Verb RunAs -Wait -PassThru -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{path}'); exit $p.ExitCode"
);
let status = Command::new("powershell")
.args(["-NoProfile", "-Command", &elevated])
.status()?;
let _ = fs::remove_file(&script_path);
if !status.success() {
return Err(anyhow!("removing FieldLens Print was cancelled or failed"));
}
Ok(())
}
#[cfg(target_os = "windows")]
fn ps_quote(value: &str) -> String {
value.replace('\'', "''")
}
#[cfg(target_os = "linux")]
fn list_linux_printers() -> Result<Vec<SystemPrinter>> {
let output = match Command::new("lpstat").arg("-v").output() {
Ok(output) => output,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Err(anyhow!(
"CUPS client tools are missing. Install the cups-client package, then reopen FieldLens Print."
))
}
Err(error) => return Err(error).context("query CUPS printers with lpstat"),
};
if !output.status.success() {
return Err(anyhow!(
"CUPS printer query failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
let mut printers = Vec::new();
for line in String::from_utf8_lossy(&output.stdout).lines() {
let Some(rest) = line.strip_prefix("device for ") else {
continue;
};
let Some((name, uri)) = rest.split_once(": ") else {
continue;
};
// Ignore placeholder/dummy queues. They can expose a PPD but are not a
// usable physical destination and can make Chromium reject the cloned
// FieldLens queue as "not installed correctly".
if uri == "///dev/null" || uri == "file:///dev/null" {
continue;
}
let address = socket_uri_to_address(uri);
printers.push(SystemPrinter {
name: name.to_string(),
driver: String::new(),
port: uri.to_string(),
address,
is_virtual: is_virtual_printer_name(name),
});
}
printers.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
Ok(printers)
}
#[cfg(target_os = "linux")]
fn socket_uri_to_address(uri: &str) -> Option<String> {
let value = uri.strip_prefix("socket://")?;
let authority = value.split('/').next().unwrap_or(value);
if authority.contains(':') {
Some(authority.to_string())
} else if authority.is_empty() {
None
} else {
Some(format!("{authority}:9100"))
}
}
#[cfg(target_os = "linux")]
fn linux_virtual_printer_status() -> Result<VirtualPrinterStatus> {
let printers = list_linux_printers()?;
let installed = printers
.iter()
.any(|printer| printer.name.eq_ignore_ascii_case("FieldLens-Print"));
if !installed {
return Ok(VirtualPrinterStatus {
installed: false,
name: VIRTUAL_PRINTER_NAME.into(),
detail: "Not installed yet. Choose the physical destination printer, then create it.".into(),
});
}
let state = Command::new("lpstat")
.args(["-p", "FieldLens-Print"])
.output()
.context("query FieldLens Print CUPS state")?;
let accepting = Command::new("lpstat")
.args(["-a", "FieldLens-Print"])
.output()
.context("query FieldLens Print accepting state")?;
let state_text = String::from_utf8_lossy(&state.stdout);
let accepting_text = String::from_utf8_lossy(&accepting.stdout);
let enabled = state.status.success()
&& !state_text.to_ascii_lowercase().contains("disabled");
let accepts_jobs = accepting.status.success()
&& accepting_text.to_ascii_lowercase().contains("accepting requests");
let ready = enabled && accepts_jobs;
Ok(VirtualPrinterStatus {
installed: ready,
name: VIRTUAL_PRINTER_NAME.into(),
detail: if ready {
"Ready. FieldLens Print sends to the physical printer; FieldLens Test Capture runs the same capture/render path but discards the rendered output.".into()
} else {
"FieldLens Print exists but CUPS has it disabled or not accepting jobs. Click Create / repair FieldLens Print.".into()
},
})
}
#[cfg(target_os = "linux")]
fn run_cups_repair_command(program: &str, args: &[&str]) -> Result<()> {
let direct = Command::new(program).args(args).status();
if matches!(direct, Ok(status) if status.success()) {
return Ok(());
}
let status = Command::new("pkexec")
.arg(program)
.args(args)
.status()
.with_context(|| format!("run elevated {program}"))?;
if !status.success() {
return Err(anyhow!("{program} failed; administrator approval may be required"));
}
Ok(())
}
#[cfg(target_os = "linux")]
fn cups_filter_dir() -> Result<&'static Path> {
for candidate in ["/usr/lib/cups/filter", "/usr/libexec/cups/filter"] {
let path = Path::new(candidate);
if path.is_dir() {
return Ok(path);
}
}
Err(anyhow!("CUPS filter directory was not found"))
}
#[cfg(target_os = "linux")]
fn run_linux_admin(program: &str, args: &[String], context: &str) -> Result<()> {
let direct = Command::new(program).args(args).status();
if matches!(direct, Ok(status) if status.success()) {
return Ok(());
}
let status = Command::new("pkexec")
.arg(program)
.args(args)
.status()
.with_context(|| format!("run elevated {context}"))?;
if !status.success() {
return Err(anyhow!("{context} failed; administrator approval may be required"));
}
Ok(())
}
#[cfg(target_os = "linux")]
fn install_linux_capture_filter() -> Result<()> {
let filter_dir = cups_filter_dir()?;
let filter_path = filter_dir.join("fieldlens-capture");
let temp_filter = std::env::temp_dir().join(format!(
"fieldlens-capture-{}.py",
uuid::Uuid::new_v4()
));
fs::write(&temp_filter, FIELDLENS_CUPS_FILTER).context("write FieldLens CUPS pre-filter")?;
let install_filter_args = vec![
"-m".to_string(),
"0755".to_string(),
temp_filter.to_string_lossy().into_owned(),
filter_path.to_string_lossy().into_owned(),
];
let filter_result = run_linux_admin(
"install",
&install_filter_args,
"install FieldLens CUPS pre-filter",
);
let _ = fs::remove_file(&temp_filter);
filter_result?;
let spool_args = vec![
"-d".to_string(),
"-m".to_string(),
"0777".to_string(),
FIELDLENS_CUPS_SPOOL_DIR.to_string(),
];
run_linux_admin("install", &spool_args, "create FieldLens CUPS capture spool")?;
Ok(())
}
#[cfg(target_os = "linux")]
fn build_fieldlens_ppd(target_printer: &str) -> Result<PathBuf> {
let source = PathBuf::from("/etc/cups/ppd").join(format!("{target_printer}.ppd"));
if !source.exists() {
return Err(anyhow!(
"CUPS PPD for '{target_printer}' was not found; FieldLens needs the installed printer definition to preserve browser print capabilities"
));
}
let original = match fs::read_to_string(&source) {
Ok(contents) => contents,
Err(direct_error) => {
// CUPS commonly stores PPDs as root:lp 0640. Desktop users can
// enumerate the queue but cannot necessarily read its PPD.
// Reuse the existing PolicyKit setup flow for a read-only fallback.
let output = Command::new("pkexec")
.arg("cat")
.arg(&source)
.output()
.with_context(|| {
format!(
"read target printer PPD {} (direct read failed: {direct_error})",
source.display()
)
})?;
if !output.status.success() {
return Err(anyhow!(
"could not read target printer PPD {}: {}",
source.display(),
String::from_utf8_lossy(&output.stderr).trim()
));
}
String::from_utf8(output.stdout)
.with_context(|| format!("decode target printer PPD {}", source.display()))?
}
};
let mut cleaned = original
.lines()
.filter(|line| !line.contains("fieldlens-capture"))
.collect::<Vec<_>>()
.join("\n");
cleaned.push('\n');
let prefilter = concat!(
"*% FieldLens original-document capture before printer rendering\n",
"*cupsPreFilter: \"application/pdf 0 fieldlens-capture\"\n"
);
if let Some(index) = cleaned.rfind("*% End") {
cleaned.insert_str(index, prefilter);
} else {
cleaned.push_str(prefilter);
}
let target = std::env::temp_dir().join(format!(
"fieldlens-{}.ppd",
uuid::Uuid::new_v4()
));
fs::write(&target, cleaned).context("write FieldLens derived PPD")?;
Ok(target)
}
#[cfg(target_os = "linux")]
fn install_linux_virtual_printer(target_printer: &str) -> Result<()> {
let printers = list_linux_printers()?;
let target = printers
.iter()
.find(|printer| printer.name == target_printer && !printer.is_virtual)
.ok_or_else(|| anyhow!(
"selected printer '{target_printer}' is not installed or available in CUPS"
))?;
install_linux_capture_filter()?;
let ppd_path = build_fieldlens_ppd(target_printer)?;
let production_args = vec![
"-p".to_string(),
"FieldLens-Print".to_string(),
"-E".to_string(),
"-v".to_string(),
target.port.clone(),
"-P".to_string(),
ppd_path.to_string_lossy().into_owned(),
"-D".to_string(),
VIRTUAL_PRINTER_NAME.to_string(),
];
run_linux_admin(
"lpadmin",
&production_args,
"configure FieldLens production CUPS printer",
)?;
let test_args = vec![
"-p".to_string(),
TEST_VIRTUAL_PRINTER_QUEUE.to_string(),
"-E".to_string(),
"-v".to_string(),
TEST_CAPTURE_DEVICE_URI.to_string(),
"-P".to_string(),
ppd_path.to_string_lossy().into_owned(),
"-D".to_string(),
TEST_VIRTUAL_PRINTER_NAME.to_string(),
];
let test_result = run_linux_admin(
"lpadmin",
&test_args,
"configure FieldLens test-capture CUPS printer",
);
let _ = fs::remove_file(&ppd_path);
test_result?;
// Explicitly repair both CUPS state flags because Chromium disables Print
// when either is stale.
for queue in ["FieldLens-Print", TEST_VIRTUAL_PRINTER_QUEUE] {
run_cups_repair_command("cupsenable", &[queue])?;
run_cups_repair_command("cupsaccept", &[queue])?;
}
Ok(())
}
#[cfg(target_os = "linux")]
fn remove_linux_virtual_printer() -> Result<()> {
for queue in ["FieldLens-Print", TEST_VIRTUAL_PRINTER_QUEUE] {
let args = vec!["-x".to_string(), queue.to_string()];
let direct = Command::new("lpadmin").args(&args).status();
if matches!(direct, Ok(status) if status.success()) {
continue;
}
let status = Command::new("pkexec")
.arg("lpadmin")
.args(&args)
.status()
.context("run elevated CUPS printer removal")?;
if !status.success() {
return Err(anyhow!("removing FieldLens CUPS printer '{queue}' failed"));
}
}
Ok(())
}