From 366bd742250b58c22ce7df74473cdaa1fd55ce87 Mon Sep 17 00:00:00 2001 From: kfattic <6775519+kfattic@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:23:31 -0500 Subject: [PATCH] Flag queues whose driver filters need Rosetta or are missing Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 4 + README.md | 2 + Sources/CUPSAdminApp/ContentView.swift | 7 +- Sources/CUPSAdminApp/PrinterDetailModel.swift | 7 + Sources/CUPSAdminApp/PrinterDetailView.swift | 12 +- Sources/CUPSAdminApp/PrinterStore.swift | 7 + Sources/CupsKit/DriverFilters.swift | 157 ++++++++++++++++++ Sources/CupsKit/PPD.swift | 5 + .../cupsadmin/Commands/PPDReportCommand.swift | 16 ++ .../cupsadmin/Commands/PrinterCommands.swift | 43 +++++ Sources/cupsadmin/main.swift | 4 +- Tests/CupsKitTests/DriverFilterTests.swift | 103 ++++++++++++ 12 files changed, 362 insertions(+), 5 deletions(-) create mode 100644 Sources/CupsKit/DriverFilters.swift create mode 100644 Tests/CupsKitTests/DriverFilterTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index 74dca1e..4b07e0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,10 @@ Private maintainer notes, if present, are in `CLAUDE.local.md` (gitignored). - **Queue defaults are written with `lpadmin -p -o key=value`** (into the queue's PPD / printers.conf, for every user) — never `lpoptions`. `~/.cups/lpoptions` is only read, to point out a per-user override. +- **Driver filter check** (`DriverFilters.swift`): resolves each `*cupsFilter`/`*cupsFilter2` program + (bare names under `/usr/libexec/cups/filter`, skipping `maxsize(n)` and `-`) and reads its Mach-O header + directly — any arm64 CPU type counts as native, including subtypes `lipo` can't name. Intel-only → + "Driver needs Rosetta"; not found → "Driver filter missing" (shown first). - **PPD options** keep their `*OpenGroup`, UI type and `*ParamCustom` type/range. Custom values are written as `Custom.` and quoted when they contain spaces (unquoted, `lpadmin` silently truncates). - **Quick actions** are vendor-neutral. `QuickActions.swift` defines the actions; what each writes comes diff --git a/README.md b/README.md index 4cc97c0..d806b71 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Requires macOS 14 or later. Universal (Apple silicon and Intel). ``` cupsadmin printers # every queue: state, reasons, accepting, shared, URI +cupsadmin printers --rosetta # only queues whose driver filters are Intel-only or missing cupsadmin printer # one queue in detail (--all for every IPP attribute) cupsadmin jobs [queue] # active jobs (--completed, --all-jobs) cupsadmin cancel # cancel one job @@ -109,6 +110,7 @@ Or put those three lines (without `export`) in a `build.env` next to `build.sh`; - Queue defaults written by `set`, `quick` and the app's Options page go into `/etc/cups/ppd/.ppd` via `lpadmin`, so they apply to every user on the Mac — the same thing the web UI's "Set Default Options" did. Per-user `~/.cups/lpoptions` overrides still win for that user's own jobs. - PPD files are world-readable. A locked-print password or login password set as a queue default is stored in plain text; the tool warns before doing that. User codes are accounting codes, not secrets. - New queues default to `printer-is-shared=false`. `lpadmin` defaults to shared; this tool doesn't. +- Driver filters named in a queue's PPD (`*cupsFilter`, `*cupsFilter2`) are checked for an arm64 slice. Queues whose filters are Intel-only show "Driver needs Rosetta" in the app's header and sidebar tooltip and a `WARNING` line in `cupsadmin printer` and `ppdreport`; filters that don't exist show "Driver filter missing". `cupsadmin printers --rosetta` lists the affected queues, for fleet reporting. ## About diff --git a/Sources/CUPSAdminApp/ContentView.swift b/Sources/CUPSAdminApp/ContentView.swift index 8c0f252..4033736 100644 --- a/Sources/CUPSAdminApp/ContentView.swift +++ b/Sources/CUPSAdminApp/ContentView.swift @@ -210,7 +210,8 @@ struct SidebarView: View { List(selection: $selection) { Section("Printers") { ForEach(filtered) { printer in - PrinterRow(printer: printer, isDefault: store.defaultPrinter == printer.name) + PrinterRow(printer: printer, isDefault: store.defaultPrinter == printer.name, + driverWarning: store.driverFilters[printer.name]?.headline) .tag(printer.name) .contextMenu { menu(for: printer) } } @@ -311,6 +312,8 @@ struct QuickActionItems: View { struct PrinterRow: View { let printer: PrinterSummary var isDefault = false + /// "Driver needs Rosetta" / "Driver filter missing", added to the tooltip. + var driverWarning: String? var body: some View { HStack(spacing: 8) { @@ -339,7 +342,7 @@ struct PrinterRow: View { } } .badge(printer.jobCount) - .help(printer.stateSummary) + .help(driverWarning.map { "\(printer.stateSummary) · \($0)" } ?? printer.stateSummary) .accessibilityElement(children: .combine) .accessibilityValue(printer.stateSummary) } diff --git a/Sources/CUPSAdminApp/PrinterDetailModel.swift b/Sources/CUPSAdminApp/PrinterDetailModel.swift index 2e46aed..c01f1de 100644 --- a/Sources/CUPSAdminApp/PrinterDetailModel.swift +++ b/Sources/CUPSAdminApp/PrinterDetailModel.swift @@ -37,6 +37,8 @@ enum JobFilter: String, CaseIterable, Identifiable { final class PrinterDetailModel { let queue: String private(set) var printer: IPPGroup? + /// Intel-only or missing driver filters, from the queue's PPD. + private(set) var driverFilters: DriverFilterReport? private(set) var jobs: [JobSummary] = [] private(set) var loadError: String? private(set) var isWorking = false @@ -73,6 +75,11 @@ final class PrinterDetailModel { do { printer = try await store.client.getPrinterAttributes(queue: queue, requested: Self.headerAttributes) loadError = nil + if let text = try await store.client.getPPD(queue: queue) { + driverFilters = await Task.detached { DriverFilterCheck.check(PPD(text: text)) }.value + } else { + driverFilters = nil + } } catch { loadError = String(describing: error) store.report("ERROR: \(queue): \(error)") diff --git a/Sources/CUPSAdminApp/PrinterDetailView.swift b/Sources/CUPSAdminApp/PrinterDetailView.swift index 541118f..d3d51e5 100644 --- a/Sources/CUPSAdminApp/PrinterDetailView.swift +++ b/Sources/CUPSAdminApp/PrinterDetailView.swift @@ -12,7 +12,7 @@ struct PrinterDetailView: View { var body: some View { VStack(spacing: 0) { if let printer = model.printer { - PrinterHeaderView(printer: printer, ppdPath: model.ppdPath) + PrinterHeaderView(printer: printer, ppdPath: model.ppdPath, driverFilters: model.driverFilters) Divider() } else if let error = model.loadError { ContentUnavailableView("Can’t Load \(model.queue)", systemImage: "exclamationmark.triangle", @@ -35,6 +35,7 @@ struct PrinterDetailView: View { struct PrinterHeaderView: View { let printer: IPPGroup let ppdPath: String? + let driverFilters: DriverFilterReport? private var state: Int? { printer.int("printer-state") } private var accepting: Bool { printer.bool("printer-is-accepting-jobs") ?? true } @@ -64,6 +65,13 @@ struct PrinterHeaderView: View { } LabeledContent("Accepting jobs", value: accepting ? "Yes" : "No") LabeledContent("Shared", value: printer.bool("printer-is-shared") == true ? "Yes" : "No") + if let headline = driverFilters?.headline { + LabeledContent("Driver") { + Label(headline, systemImage: "exclamationmark.triangle.fill") + .symbolRenderingMode(.multicolor) + } + .help(driverFilters?.detail ?? headline) + } } } Form { @@ -93,7 +101,7 @@ struct PrinterHeaderView: View { .formStyle(.grouped) .scrollDisabled(true) .textSelection(.enabled) - .frame(height: 262) + .frame(height: driverFilters?.isFlagged == true ? 300 : 262) } } diff --git a/Sources/CUPSAdminApp/PrinterStore.swift b/Sources/CUPSAdminApp/PrinterStore.swift index 5d93455..d862968 100644 --- a/Sources/CUPSAdminApp/PrinterStore.swift +++ b/Sources/CUPSAdminApp/PrinterStore.swift @@ -103,6 +103,8 @@ final class PrinterStore { private(set) var defaultPrinter: String? /// Driver PPD + IPP attributes per queue, for enabling Quick Actions. private(set) var drivers: [String: DriverContext] = [:] + /// Filter check per queue (Intel-only or missing filters), from the cached PPDs. + private(set) var driverFilters: [String: DriverFilterReport] = [:] var failure: Failure? let client = CupsClient() @@ -201,6 +203,11 @@ final class PrinterStore { for printer in printers where drivers[printer.name] == nil { if let context = try? await DriverContext.load(client: client, queue: printer.name) { drivers[printer.name] = context + if let ppd = context.ppd { + driverFilters[printer.name] = await Task.detached { DriverFilterCheck.check(ppd) }.value + } else { + driverFilters[printer.name] = nil + } } } } diff --git a/Sources/CupsKit/DriverFilters.swift b/Sources/CupsKit/DriverFilters.swift new file mode 100644 index 0000000..bc962eb --- /dev/null +++ b/Sources/CupsKit/DriverFilters.swift @@ -0,0 +1,157 @@ +import Darwin +import Foundation + +/// One filter program a PPD names in `*cupsFilter` / `*cupsFilter2`, and whether it can run natively. +public struct DriverFilter: Hashable { + public enum Status: Hashable { + /// Has an arm64 slice (arm64, arm64e or newer subtypes). + case native(architectures: [String]) + /// Intel only: needs Rosetta on Apple silicon. + case intelOnly(architectures: [String]) + /// A script (`#!`); runs wherever its interpreter does. + case script(interpreter: String) + /// `-`: no program, cupsd passes the data through. + case passthrough + case missing + /// Exists but isn't a Mach-O binary or script this check understands. + case unrecognized + } + + public let keyword: String + /// The line's value, e.g. `application/vnd.cups-postscript 0 /Library/Printers/…/pstopsRV2`. + public let line: String + public let program: String + /// Resolved absolute path; nil for `-`. + public let path: String? + public let status: Status + + public var name: String { (program as NSString).lastPathComponent } +} + +public struct DriverFilterReport: Hashable { + public let filters: [DriverFilter] + + public var intelOnly: [DriverFilter] { filters.filter { if case .intelOnly = $0.status { return true } else { return false } } } + public var missing: [DriverFilter] { filters.filter { $0.status == .missing } } + public var needsRosetta: Bool { !intelOnly.isEmpty } + public var hasMissingFilter: Bool { !missing.isEmpty } + public var isFlagged: Bool { needsRosetta || hasMissingFilter } + + /// "Driver filter missing" (worse, shown first) or "Driver needs Rosetta"; nil when fine. + public var headline: String? { + if hasMissingFilter { return "Driver filter missing" } + if needsRosetta { return "Driver needs Rosetta" } + return nil + } + + /// e.g. "pstopsRV2, commandfilterRV1 are Intel-only (x86_64); /Library/…/foo not found". + public var detail: String? { + var parts: [String] = [] + if !missing.isEmpty { + parts.append(missing.compactMap(\.path).joined(separator: ", ") + " not found") + } + if !intelOnly.isEmpty { + let names = intelOnly.map(\.name) + let archs = Set(intelOnly.flatMap { if case .intelOnly(let a) = $0.status { return a } else { return [] } }).sorted() + parts.append("\(names.joined(separator: ", ")) \(names.count == 1 ? "is" : "are") Intel-only (\(archs.joined(separator: ", ")))") + } + return parts.isEmpty ? nil : parts.joined(separator: "; ") + } + + /// One line for CLI output: "Driver needs Rosetta — pstopsRV2 is Intel-only (x86_64)". + public var warning: String? { + guard let headline, let detail else { return nil } + return "\(headline) — \(detail)" + } +} + +public enum DriverFilterCheck { + /// Where cupsd looks for filters named without a path. + public static let filterDirectory = "/usr/libexec/cups/filter" + + public static func check(_ ppd: PPD, filterDirectory: String = filterDirectory) -> DriverFilterReport { + var seen = Set() + var filters: [DriverFilter] = [] + for (keyword, value) in ppd.filterLines { + // cupsFilter: "type cost program"; cupsFilter2: "source destination cost [maxsize(n)] program". + var fields = value.split(separator: " ", omittingEmptySubsequences: true).map(String.init) + let skip = keyword == "cupsFilter2" ? 3 : 2 + guard fields.count > skip else { continue } + fields.removeFirst(skip) + if let first = fields.first, first.hasPrefix("maxsize("), first.hasSuffix(")") { fields.removeFirst() } + guard !fields.isEmpty else { continue } + let program = fields.joined(separator: " ") + let path: String? = program == "-" ? nil + : program.hasPrefix("/") ? program : (filterDirectory as NSString).appendingPathComponent(program) + guard seen.insert(path ?? "-\(keyword)").inserted else { continue } + let status: DriverFilter.Status = path.map(Self.status(atPath:)) ?? .passthrough + filters.append(DriverFilter(keyword: keyword, line: value, program: program, path: path, status: status)) + } + return DriverFilterReport(filters: filters) + } + + /// Reads the file's header: Mach-O (thin or universal) or `#!` script. + public static func status(atPath path: String) -> DriverFilter.Status { + guard FileManager.default.fileExists(atPath: path) else { return .missing } + guard let handle = FileHandle(forReadingAtPath: path) else { return .unrecognized } + defer { try? handle.close() } + let header = [UInt8](handle.readData(ofLength: 4096)) + return status(header: header) + } + + static func status(header b: [UInt8]) -> DriverFilter.Status { + guard b.count >= 8 else { return .unrecognized } + if b[0] == UInt8(ascii: "#"), b[1] == UInt8(ascii: "!") { + let line = b.prefix { $0 != UInt8(ascii: "\n") }.dropFirst(2) + return .script(interpreter: String(decoding: line, as: UTF8.self).trimmingCharacters(in: .whitespaces)) + } + func be32(_ i: Int) -> UInt32 { UInt32(b[i]) << 24 | UInt32(b[i + 1]) << 16 | UInt32(b[i + 2]) << 8 | UInt32(b[i + 3]) } + func le32(_ i: Int) -> UInt32 { UInt32(b[i + 3]) << 24 | UInt32(b[i + 2]) << 16 | UInt32(b[i + 1]) << 8 | UInt32(b[i]) } + + var cpuTypes: [(type: UInt32, subtype: UInt32)] = [] + switch be32(0) { + case 0xCAFEBABE, 0xCAFEBABF: // universal (fat32 / fat64) + let count = Int(be32(4)) + let entrySize = be32(0) == 0xCAFEBABF ? 32 : 20 + guard count > 0, count < 32 else { return .unrecognized } // Java class files share the magic + for i in 0 ..< count { + let offset = 8 + i * entrySize + guard offset + 8 <= b.count else { break } + cpuTypes.append((be32(offset), be32(offset + 4) & 0x00FF_FFFF)) + } + default: + switch le32(0) { + case 0xFEEDFACF, 0xFEEDFACE: // thin Mach-O, little-endian + cpuTypes.append((le32(4), le32(8) & 0x00FF_FFFF)) + default: + return .unrecognized + } + } + let names = cpuTypes.map { architectureName(type: $0.type, subtype: $0.subtype) } + if cpuTypes.contains(where: { $0.type == 0x0100_000C }) { return .native(architectures: names) } + if cpuTypes.contains(where: { $0.type == 0x0100_0007 || $0.type == 7 }) { return .intelOnly(architectures: names) } + return .unrecognized + } + + static func architectureName(type: UInt32, subtype: UInt32) -> String { + switch type { + case 0x0100_0007: return "x86_64" + case 7: return "i386" + case 0x0100_000C: return subtype == 2 ? "arm64e" : "arm64" + case 0x0200_000C: return "arm64_32" + case 12: return "arm" + default: return "cpu\(type)" + } + } + + /// True on Apple silicon (also when this process itself runs under Rosetta). + public static var hostIsAppleSilicon: Bool { + var value: Int32 = 0 + var size = MemoryLayout.size + return sysctlbyname("hw.optional.arm64", &value, &size, nil, 0) == 0 && value == 1 + } + + public static var rosettaInstalled: Bool { + FileManager.default.fileExists(atPath: "/Library/Apple/usr/libexec/oah/libRosettaRuntime") + } +} diff --git a/Sources/CupsKit/PPD.swift b/Sources/CupsKit/PPD.swift index 1ff568e..9e26fbf 100644 --- a/Sources/CupsKit/PPD.swift +++ b/Sources/CupsKit/PPD.swift @@ -57,6 +57,8 @@ public struct PPD { public private(set) var groups: [String] = [] /// Top-level `*Key: value` attributes (NickName, Manufacturer, ...), first occurrence wins. public private(set) var attributes: [String: String] = [:] + /// Every `*cupsFilter` / `*cupsFilter2` line, in order, with the quotes removed. + public private(set) var filterLines: [(keyword: String, value: String)] = [] public init(text: String) { var defaults: [String: String] = [:] @@ -122,6 +124,9 @@ public struct PPD { let (choice, label) = Self.splitText(String(parts[1])) current!.choices.append(choice) current!.choiceList.append(Choice(keyword: choice, text: label ?? choice)) + } else if parts.count == 1, mainKey == "cupsFilter" || mainKey == "cupsFilter2" { + filterLines.append((mainKey, value.trimmingCharacters(in: CharacterSet(charactersIn: "\"")))) + if attributes[mainKey] == nil { attributes[mainKey] = filterLines.last!.value } } else if parts.count == 1, attributes[mainKey] == nil { attributes[mainKey] = value.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) } diff --git a/Sources/cupsadmin/Commands/PPDReportCommand.swift b/Sources/cupsadmin/Commands/PPDReportCommand.swift index 4b28ec9..6f83b55 100644 --- a/Sources/cupsadmin/Commands/PPDReportCommand.swift +++ b/Sources/cupsadmin/Commands/PPDReportCommand.swift @@ -37,6 +37,22 @@ enum PPDReportCommand { action.isAvailable(in: context) ? action.id : "\(action.id) (unavailable)" } print("Quick: \(quick.joined(separator: ", "))") + let filters = DriverFilterCheck.check(ppd) + if !filters.filters.isEmpty { + print("Filters: " + filters.filters.map { filter -> String in + switch filter.status { + case .native(let archs): return "\(filter.name) (\(archs.joined(separator: " ")))" + case .intelOnly(let archs): return "\(filter.name) (\(archs.joined(separator: " ")) only)" + case .script: return "\(filter.name) (script)" + case .passthrough: return "- (pass-through)" + case .missing: return "\(filter.name) (missing)" + case .unrecognized: return "\(filter.name) (unrecognized)" + } + }.joined(separator: ", ")) + } + if let warning = filters.warning { + print("WARNING: \(warning)") + } for group in ppd.groups { print("\n\(group)") diff --git a/Sources/cupsadmin/Commands/PrinterCommands.swift b/Sources/cupsadmin/Commands/PrinterCommands.swift index 88efc57..888811e 100644 --- a/Sources/cupsadmin/Commands/PrinterCommands.swift +++ b/Sources/cupsadmin/Commands/PrinterCommands.swift @@ -100,8 +100,51 @@ enum PrinterCommand { : label + ":" + String(repeating: " ", count: width - label.count) print("\(heading) \(value)") } + if let ppdText = try? await client.getPPD(queue: queue), + let warning = DriverFilterCheck.check(PPD(text: ppdText)).warning { + print("\nWARNING: \(warning)") + } print("\n(use --all for every attribute)") return "OK: \(queue) \(Format.printerState(p.int("printer-state"))), reasons \(Format.reasons(p.strings("printer-state-reasons")))" } } + +/// `printers --rosetta`: queues whose PPD filters have no arm64 slice or don't exist. +enum RosettaCommand { + static func run(client: CupsClient) async throws -> String { + let printers = try await client.getPrinters(requested: ["printer-name"]) + let names = printers.compactMap { $0.string("printer-name") } + .sorted { $0.localizedStandardCompare($1) == .orderedAscending } + + var rows: [[String]] = [] + var affected = 0 + for name in names { + guard let text = try await client.getPPD(queue: name) else { continue } + let report = DriverFilterCheck.check(PPD(text: text)) + guard report.isFlagged else { continue } + affected += 1 + let flagged = report.missing + report.intelOnly + for (i, filter) in flagged.enumerated() { + let what: String + switch filter.status { + case .intelOnly(let archs): what = archs.joined(separator: " ") + case .missing: what = "missing" + default: what = "" + } + rows.append([i == 0 ? name : "", i == 0 ? (report.headline ?? "") : "", what, filter.path ?? filter.program]) + } + } + + let host = DriverFilterCheck.hostIsAppleSilicon + ? "Apple silicon, Rosetta \(DriverFilterCheck.rosettaInstalled ? "installed" : "NOT installed")" + : "Intel Mac (Intel-only filters run natively here)" + if rows.isEmpty { + print("No queues need Rosetta or have missing driver filters. This Mac: \(host).") + return "OK: none of \(names.count) queues need Rosetta or have missing filters" + } + printTable(headers: ["QUEUE", "PROBLEM", "ARCH", "FILTER"], rows: rows) + print("\nThis Mac: \(host).") + return "OK: \(affected) of \(names.count) queues need Rosetta or have missing filters" + } +} diff --git a/Sources/cupsadmin/main.swift b/Sources/cupsadmin/main.swift index 70ff1b0..35ebfd5 100644 --- a/Sources/cupsadmin/main.swift +++ b/Sources/cupsadmin/main.swift @@ -28,6 +28,7 @@ let usage = """ usage: cupsadmin [args] printers all queues: state, reasons, accepting, shared, device URI + printers --rosetta only queues whose driver filters are Intel-only or missing printer [--all] one queue in detail (--all dumps every IPP attribute) jobs [queue] [--completed | --all-jobs] active jobs (all queues unless one is given) @@ -80,9 +81,10 @@ func runCommand(_ args: [String]) async throws -> String { return "OK: help" case "printers": + let rosetta = takeFlag("--rosetta") try rejectUnknownFlags() guard rest.isEmpty else { throw CupsAdminError.usage("printers takes no arguments") } - return try await PrintersCommand.run(client: client) + return rosetta ? try await RosettaCommand.run(client: client) : try await PrintersCommand.run(client: client) case "printer": let dumpAll = takeFlag("--all") diff --git a/Tests/CupsKitTests/DriverFilterTests.swift b/Tests/CupsKitTests/DriverFilterTests.swift new file mode 100644 index 0000000..8ec2cd8 --- /dev/null +++ b/Tests/CupsKitTests/DriverFilterTests.swift @@ -0,0 +1,103 @@ +@testable import CupsKit +import Foundation +import Testing + +private func le32(_ v: UInt32) -> [UInt8] { [UInt8(v & 0xFF), UInt8(v >> 8 & 0xFF), UInt8(v >> 16 & 0xFF), UInt8(v >> 24)] } +private func be32(_ v: UInt32) -> [UInt8] { [UInt8(v >> 24), UInt8(v >> 16 & 0xFF), UInt8(v >> 8 & 0xFF), UInt8(v & 0xFF)] } +private let x86_64: UInt32 = 0x0100_0007, arm64: UInt32 = 0x0100_000C + +/// Thin 64-bit Mach-O header (little-endian). +private func thin(_ cpu: UInt32, subtype: UInt32 = 0) -> [UInt8] { + var bytes: [UInt8] = le32(0xFEEDFACF) + bytes += le32(cpu) + bytes += le32(subtype) + bytes += [UInt8](repeating: 0, count: 20) + return bytes +} + +/// Universal (fat32) header with the given slices. +private func fat(_ slices: [(UInt32, UInt32)]) -> [UInt8] { + var bytes: [UInt8] = be32(0xCAFEBABE) + bytes += be32(UInt32(slices.count)) + for (cpu, subtype) in slices { + bytes += be32(cpu) + bytes += be32(subtype) + bytes += be32(0x4000) // offset + bytes += be32(0x1000) // size + bytes += be32(14) // align + } + return bytes +} + +@Suite struct MachOArchitectures { + @Test func thinAndUniversalBinaries() { + #expect(DriverFilterCheck.status(header: thin(arm64)) == .native(architectures: ["arm64"])) + #expect(DriverFilterCheck.status(header: thin(x86_64)) == .intelOnly(architectures: ["x86_64"])) + #expect(DriverFilterCheck.status(header: fat([(x86_64, 3), (arm64, 2)])) == .native(architectures: ["x86_64", "arm64e"])) + #expect(DriverFilterCheck.status(header: fat([(x86_64, 3), (7, 3)])) == .intelOnly(architectures: ["x86_64", "i386"])) + // A newer arm64 subtype lipo can't name (seen on macOS 27's rastertohp) still counts as arm64. + #expect(DriverFilterCheck.status(header: fat([(x86_64, 3), (arm64, 12)])) == .native(architectures: ["x86_64", "arm64"])) + } + + @Test func scriptsAndOtherFiles() { + #expect(DriverFilterCheck.status(header: Array("#!/bin/sh\nexec foo\n".utf8)) == .script(interpreter: "/bin/sh")) + #expect(DriverFilterCheck.status(header: Array("%PDF-1.4 not a filter".utf8)) == .unrecognized) + #expect(DriverFilterCheck.status(header: [0xCA, 0xFE, 0xBA, 0xBE, 0, 0, 0, 52]) == .unrecognized) // Java class file + } + + @Test(.enabled(if: FileManager.default.fileExists(atPath: "/usr/libexec/cups/filter/rastertohp"))) + func appleFilterIsNative() { + guard case .native = DriverFilterCheck.status(atPath: "/usr/libexec/cups/filter/rastertohp") else { + Issue.record("rastertohp should have an arm64 slice"); return + } + } +} + +@Suite struct FilterLines { + @Test func resolvesAndClassifiesEveryFilter() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("cupsadmin-filters-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + try Data(thin(arm64)).write(to: dir.appendingPathComponent("nativefilter")) + let intel = dir.appendingPathComponent("Vendor Filters/pstopsvendor") + try FileManager.default.createDirectory(at: intel.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(fat([(x86_64, 3)])).write(to: intel) + + let ppd = PPD(text: """ + *PPD-Adobe: "4.3" + *cupsFilter: "application/vnd.cups-raster 50 nativefilter" + *cupsFilter: "application/vnd.cups-postscript 0 \(intel.path)" + *cupsFilter2: "application/vnd.cups-pdf application/pdf 0 maxsize(0) -" + *cupsFilter: "application/vnd.cups-command 0 /Library/Printers/Nobody/Filters/gone" + *cupsFilter: "application/vnd.cups-postscript 0 \(intel.path)" + """) + #expect(ppd.filterLines.count == 5) + + let report = DriverFilterCheck.check(ppd, filterDirectory: dir.path) + #expect(report.filters.count == 4) // duplicate pstopsvendor line counted once + #expect(report.filters[0].status == .native(architectures: ["arm64"])) + #expect(report.filters[0].path == dir.appendingPathComponent("nativefilter").path) + #expect(report.filters[1].status == .intelOnly(architectures: ["x86_64"])) + #expect(report.filters[2].status == .passthrough) + #expect(report.filters[3].status == .missing) + + #expect(report.needsRosetta && report.hasMissingFilter) + #expect(report.headline == "Driver filter missing") // missing outranks Rosetta + #expect(report.detail == "/Library/Printers/Nobody/Filters/gone not found; pstopsvendor is Intel-only (x86_64)") + } + + @Test func rosettaOnlyAndClean() { + let passthrough = PPD(text: "*cupsFilter2: \"application/vnd.cups-pdf application/pdf 0 maxsize(0) -\"\n*cupsFilter: \"image/urf 10 -\"\n") + let clean = DriverFilterCheck.check(passthrough) + #expect(!clean.isFlagged && clean.headline == nil && clean.warning == nil) + + let none = DriverFilterCheck.check(PPD(text: "*NickName: \"No filters\"\n")) + #expect(none.filters.isEmpty && !none.isFlagged) + } + + @Test(.enabled(if: hasVendorPPD("RICOH IM C4500"))) + func ricohVendorPPDNamesItsFilters() throws { + let report = DriverFilterCheck.check(try #require(vendorPPD("RICOH IM C4500"))) + #expect(report.filters.map(\.name) == ["commandfilterRV1", "pstopsRV2"] || report.filters.map(\.name) == ["pstopsRV2", "commandfilterRV1"]) + } +}