From 7fbdfd978e06832d6e90f540c322d91504ac3690 Mon Sep 17 00:00:00 2001 From: Gronod Date: Wed, 9 Sep 2026 00:23:38 +0100 Subject: [PATCH] =?UTF-8?q?lp=20spool=20path=20=E2=80=94=20unmanaged=20ras?= =?UTF-8?q?ter=20jobs=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LpArgs.build: lp -d -t 'ICCery Target - ' -o AP_ColorMatchingMode=AP_ApplicationColorMatching -o AP.ColorMatchingMode=AP_ApplicationColorMatching . - Never -o raw (#92): raw is dropped by the capture filter AND skipped at build time. - Captured options win over derived fields (case-insensitive dedup); sanitise rejects ;, newlines, shell metachars — argv array only. - CupsService.printTarget: TIFF existence check, optionKeys lookup for media/bypass detection, runCaptured spawn. - ProcessID.lp(queue, page). Tests: 9 argv golden suites — both AP_* always present, captured wins, sanitise rejects ;, no raw. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ICCeryCore/Print/CupsOptionsFilter.swift | 7 +- .../ICCeryCore/Print/CupsService.swift | 21 +++ .../Sources/ICCeryCore/Print/LpArgs.swift | 126 +++++++++++++++++ .../ICCeryCore/Process/ProcessManager.swift | 7 +- Tests/ICCeryCoreTests/LpArgsTests.swift | 129 ++++++++++++++++++ 5 files changed, 285 insertions(+), 5 deletions(-) create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Print/LpArgs.swift create mode 100644 Tests/ICCeryCoreTests/LpArgsTests.swift diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift index fcf3bf1..149f4e5 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift @@ -31,10 +31,13 @@ public enum CupsOptionsFilter { "Duplex", "sides", ] - /// Keys we always drop regardless of the relevant list. + /// Keys we always drop regardless of the relevant list. `raw` is + /// included — a captured `raw=…` would re-enable CUPS raw mode and + /// bypass the raster filter that honours `AP_ApplicationColorMatching` + /// (#92). public static let alwaysDropped: Set = [ "collate", "copies", "pserrorhandler-requested", "job-sheets", - "AP_ColorMatchingMode", "AP.ColorMatchingMode", + "AP_ColorMatchingMode", "AP.ColorMatchingMode", "raw", ] /// A `key=value` pair survives when the key is non-empty, the value diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift index f21d99f..9fabd8d 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift @@ -139,6 +139,27 @@ public struct CupsService: Sendable { Set(try await optionListings(for: queue).map(\.key)) } + // MARK: - Spool (issue 15) + + /// `lp -d ` — spool one target page unmanaged. + /// Never uses `-o raw` (#92). `page` disambiguates the process id + /// when several pages are spooled in sequence. + public func printTarget( + queue: String, + tiffPath: String, + options: PrintOptions, + page: Int = 0 + ) async throws { + guard FileManager.default.fileExists(atPath: tiffPath) else { + throw CupsError.tiffMissing(tiffPath) + } + let optionKeys = (try? await self.optionKeys(for: queue)) ?? [] + let argv = try LpArgs.build( + queue: queue, tiffPath: tiffPath, + options: options, optionKeys: optionKeys) + try await run("lp", argv, id: ProcessID.lp(queue, page: page)) + } + // MARK: - PPD private func loadPPD(for queue: String) -> String? { diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/LpArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/LpArgs.swift new file mode 100644 index 0000000..3f0ed61 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/LpArgs.swift @@ -0,0 +1,126 @@ +import Foundation + +/// Errors from `buildLpArgs`. +public enum LpArgsError: LocalizedError, Equatable { + case unsanitisedOption(String) + + public var errorDescription: String? { + switch self { + case .unsanitisedOption(let option): + return "Captured CUPS option contains unsafe characters: \(option)" + } + } +} + +/// `lp` argv builder — issue 15, docs/11 `build_lp_args`. +/// +/// ``` +/// lp -d -t "ICCery Target - " +/// -o AP_ColorMatchingMode=AP_ApplicationColorMatching +/// -o AP.ColorMatchingMode=AP_ApplicationColorMatching +/// +/// +/// +/// +/// +/// +/// ``` +/// +/// - **Never `-o raw`** — `raw` skips the raster filter that honours +/// `AP_ApplicationColorMatching` (#92). +/// - Captured options **win** over explicit fields: any key already +/// present (case-insensitive) suppresses the derived `-o`. +/// - Captured keys/values are sanitised — `;`, newlines, or shell +/// metacharacters throw `unsanitisedOption`; args are passed as a +/// `Process` argv array, never through a shell. +/// - The TIFF path is always the **last** argument. +public enum LpArgs { + + /// `options` = the captured `PrintOptions`; `optionKeys` = the + /// queue's `lpoptions -l` key set (for media-key/bypass detection). + public static func build( + queue: String, + tiffPath: String, + options: PrintOptions, + optionKeys: Set + ) throws -> [String] { + var argv: [String] = [ + "-d", queue, + "-t", "ICCery Target - \((tiffPath as NSString).lastPathComponent)", + "-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching", + "-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching", + ] + var addedKeys: Set = [ + "ap_colormatchingmode", "ap.colormatchingmode", + ] + + // Captured CUPS options — sanitised, lowercased-key dedup. + if let captured = options.cupsOptions, !captured.isEmpty { + // Newlines can't survive the tokeniser — check the raw + // string so embedded line breaks are still rejected. + if captured.contains("\n") || captured.contains("\r") { + throw LpArgsError.unsanitisedOption(captured) + } + for pair in CupsParsers.lpoptions(captured) { + try sanitize(pair.key, pair.value) + let lowered = pair.key.lowercased() + // Defence in depth: never let a captured `raw` reach + // argv — `-o raw` skips the raster filter that honours + // AP_ApplicationColorMatching (#92). + if lowered == "raw" { continue } + guard !addedKeys.contains(lowered) else { continue } + addedKeys.insert(lowered) + argv += ["-o", "\(pair.key)=\(pair.value)"] + } + } + + // Media type — only when the captured options didn't carry one. + if let mediaType = options.mediaType, + let mediaKey = CupsParsers.detectMediaTypeKey(optionKeys: optionKeys), + !addedKeys.contains(mediaKey.lowercased()) { + addedKeys.insert(mediaKey.lowercased()) + argv += ["-o", "\(mediaKey)=\(mediaType)"] + } + + // Driver colour bypass — when no bypass key was captured. NOT + // gated on ppdUncorrectedPassthrough (macOS always bypasses). + let capturedKeys = Set( + CupsParsers.lpoptions(options.cupsOptions ?? "") + .map { $0.key }) + if capturedKeys.isDisjoint(with: CupsParsers.bypassKeys), + let bypass = CupsParsers.detectDriverColorBypass(optionKeys: optionKeys), + !addedKeys.contains(bypass.key.lowercased()) { + addedKeys.insert(bypass.key.lowercased()) + argv += ["-o", "\(bypass.key)=\(bypass.value)"] + } + + // Orientation — portrait=3, landscape=4. + if let orientation = options.orientation, + !addedKeys.contains("orientation-requested") { + let value = orientation == "landscape" ? "4" : "3" + addedKeys.insert("orientation-requested") + argv += ["-o", "orientation-requested=\(value)"] + } + + // PageSize — the printtarg layout page size. + if let paperSize = options.paperSize, !paperSize.isEmpty, + !addedKeys.contains("pagesize") { + argv += ["-o", "PageSize=\(paperSize)"] + } + + argv.append(tiffPath) + return argv + } + + /// Reject shell/metachar injection — args go to `Process` as an + /// argv array, but a hostile captured string must not smuggle a + /// second option or command. + static func sanitize(_ key: String, _ value: String) throws { + let forbidden = CharacterSet(charactersIn: ";\n\r`|$&<>\\\"'") + if key.rangeOfCharacter(from: forbidden) != nil + || value.rangeOfCharacter(from: forbidden) != nil + || key.isEmpty { + throw LpArgsError.unsanitisedOption("\(key)=\(value)") + } + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift index 6bfe6ab..4d692fc 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift @@ -1,7 +1,7 @@ import Foundation -/// Captured output from `runCaptured` (used by printcal/applycal — -/// the only tools whose results arrive as one-shot output). +/// Captured output from `runCaptured` — one-shot tools whose results +/// arrive as buffered stdout/stderr (printcal/applycal, CUPS tools). public struct CapturedResult: Sendable, Equatable { public let stdout: String public let stderr: String @@ -173,7 +173,8 @@ public actor ProcessManager { /// Runs a child to completion and returns all output. Reads stdout /// and stderr concurrently so a full pipe buffer can never deadlock - /// the child. Used by `printcal` / `applycal` (docs/03). + /// the child. Used by `printcal` / `applycal` (docs/03) and by + /// `CupsService` for `/usr/bin/lpstat`, `lpoptions`, `lp` (#12/#15). public func runCaptured( id: String, binary: URL, diff --git a/Tests/ICCeryCoreTests/LpArgsTests.swift b/Tests/ICCeryCoreTests/LpArgsTests.swift new file mode 100644 index 0000000..1f2d175 --- /dev/null +++ b/Tests/ICCeryCoreTests/LpArgsTests.swift @@ -0,0 +1,129 @@ +import Testing +import Foundation +@testable import ICCeryCore + +/// Issue 15 — `lp` argv goldens (docs/11 `build_lp_args`). +/// `-d`/`options`/`-t` handling is in `CupsService`; these tests cover +/// flag order, captured-option precedence, and sanitisation. +@Suite("LpArgs") +struct LpArgsTests { + + private let tiff = "/tmp/work/target_001.tif" + private let queue = "EPSON_XP_55_Series" + + private func build( + options: PrintOptions = PrintOptions(), + optionKeys: Set = [] + ) throws -> [String] { + try LpArgs.build( + queue: queue, tiffPath: tiff, + options: options, optionKeys: optionKeys) + } + + @Test("Header: -d queue -t title, both AP_* first, TIFF last") + func header() throws { + let argv = try build() + #expect(Array(argv[0...1]) == ["-d", queue]) + #expect(Array(argv[2...3]) == ["-t", "ICCery Target - target_001.tif"]) + #expect(Array(argv[4...5]) + == ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"]) + #expect(Array(argv[6...7]) + == ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"]) + #expect(argv.last == tiff) + #expect(!argv.contains { $0 == "raw" || $0 == "-o raw" }) + } + + @Test("Never emits -o raw; captured raw= is dropped") + func neverRaw() throws { + let argv = try build(options: PrintOptions( + cupsOptions: "raw=true MediaType=Photo")) + for (i, arg) in argv.enumerated() where arg == "-o" { + #expect(argv[i + 1] != "raw") + #expect(argv[i + 1] != "raw=true") + } + #expect(!argv.contains { $0.hasPrefix("raw=") }) + #expect(argv.contains("MediaType=Photo")) + } + + @Test("Captured options replayed after AP_* headers") + func capturedReplay() throws { + let argv = try build(options: PrintOptions( + cupsOptions: "InputSlot=Rear MediaType=Photo")) + let rear = argv.firstIndex(of: "InputSlot=Rear")! + let apFirst = argv.firstIndex(of: + "AP_ColorMatchingMode=AP_ApplicationColorMatching")! + #expect(rear > apFirst) + } + + @Test("Captured wins: media key present → derived media skipped") + func capturedWinsMedia() throws { + let argv = try build( + options: PrintOptions( + mediaType: "Plain", + cupsOptions: "MediaType=Glossy"), + optionKeys: ["MediaType"]) + #expect(argv.contains("MediaType=Glossy")) + #expect(!argv.contains("MediaType=Plain")) + } + + @Test("Media emitted via detected key when not captured") + func mediaDerived() throws { + let argv = try build( + options: PrintOptions(mediaType: "SemiGloss"), + optionKeys: ["CNIJMediaType", "MediaType"]) + // CNIJMediaType wins over MediaType in detection order. + #expect(argv.contains("CNIJMediaType=SemiGloss")) + #expect(!argv.contains("MediaType=SemiGloss")) + } + + @Test("Driver bypass emitted when absent, skipped when captured") + func bypassRules() throws { + let withBypass = try build( + optionKeys: ["EPIJ_CMat"]) + #expect(withBypass.contains("EPIJ_CMat=3")) + + let captured = try build( + options: PrintOptions(cupsOptions: "EPIJ_CMat=1"), + optionKeys: ["EPIJ_CMat"]) + // Captured value kept, detection not re-applied. + #expect(captured.filter { $0.hasPrefix("EPIJ_CMat") } + == ["EPIJ_CMat=1"]) + } + + @Test("Orientation: portrait=3 landscape=4; captured wins") + func orientation() throws { + #expect(try build(options: PrintOptions(orientation: "portrait")) + .contains("orientation-requested=3")) + #expect(try build(options: PrintOptions(orientation: "landscape")) + .contains("orientation-requested=4")) + #expect(!try build(options: PrintOptions( + orientation: "landscape", + cupsOptions: "orientation-requested=5")) + .contains("orientation-requested=4")) + } + + @Test("PageSize emitted unless captured") + func pageSize() throws { + #expect(try build(options: PrintOptions(paperSize: "A4")) + .contains("PageSize=A4")) + #expect(!try build(options: PrintOptions( + paperSize: "A4", cupsOptions: "PageSize=Letter")) + .contains("PageSize=A4")) + } + + @Test("Sanitise rejects `;`, newline, and shell metachars") + func sanitise() throws { + #expect(throws: LpArgsError.self) { + _ = try build(options: PrintOptions( + cupsOptions: "InputSlot=Rear;rm -rf /")) + } + #expect(throws: LpArgsError.self) { + _ = try build(options: PrintOptions( + cupsOptions: "InputSlot=Rear\nMediaType=Photo")) + } + #expect(throws: LpArgsError.self) { + _ = try build(options: PrintOptions( + cupsOptions: "InputSlot=$(whoami)")) + } + } +} -- 2.39.5