cupsadmin: CLI, CupsKit, app in progress

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kfattic
2026-09-16 13:13:07 -05:00
commit eba94aae4e
44 changed files with 7256 additions and 0 deletions

View File

@@ -0,0 +1,173 @@
import Foundation
public enum CupsAdminError: Error, CustomStringConvertible {
case usage(String)
case transport(String)
case ipp(status: UInt16, message: String?)
case failed(String)
public var description: String {
switch self {
case .usage(let m): return m
case .transport(let m), .failed(let m): return m
case .ipp(let status, let message):
let name = IPPStatus.name(status)
if let message, !message.isEmpty { return "\(name): \(message)" }
return name
}
}
}
public enum IPPStatus {
public static let notFound: UInt16 = 0x0406
public static func name(_ code: UInt16) -> String {
let known: [UInt16: String] = [
0x0400: "client-error-bad-request",
0x0401: "client-error-forbidden",
0x0402: "client-error-not-authenticated",
0x0403: "client-error-not-authorized",
0x0404: "client-error-not-possible",
0x0405: "client-error-timeout",
0x0406: "client-error-not-found",
0x0407: "client-error-gone",
0x040B: "client-error-attributes-or-values-not-supported",
0x0500: "server-error-internal-error",
0x0501: "server-error-operation-not-supported",
0x0502: "server-error-service-unavailable",
0x0503: "server-error-version-not-supported",
]
return known[code] ?? String(format: "ipp-status-0x%04x", code)
}
}
/// Talks IPP to the local cupsd over its Unix domain socket (see UnixSocketHTTP for why not TCP 631).
public final class CupsClient {
public let socketPath = "/private/var/run/cupsd"
private var nextRequestID: Int32 = 1
public init() {}
// MARK: operations
/// CUPS-Get-Printers: one printer-attributes group per queue (printers and classes).
public func getPrinters(requested: [String]) async throws -> [IPPGroup] {
let request = makeRequest(IPPOperation.cupsGetPrinters, target: serverURI, requested: requested)
return try await send(request, resource: "/").groups(.printerAttributes)
}
public func getPrinterAttributes(queue: String, requested: [String]) async throws -> IPPGroup {
let request = makeRequest(IPPOperation.getPrinterAttributes, target: printerURI(queue), requested: requested)
let response = try await send(request, resource: printerResource(queue))
guard let group = response.groups(.printerAttributes).first else {
throw CupsAdminError.transport("cupsd returned no printer attributes for \(queue)")
}
return group
}
public func queueExists(_ queue: String) async throws -> Bool {
do {
_ = try await getPrinterAttributes(queue: queue, requested: ["printer-name"])
return true
} catch CupsAdminError.ipp(let status, _) where status == IPPStatus.notFound {
return false
}
}
/// CUPS-Get-Default: the server default printer's name, or nil when there is none.
public func getDefaultPrinter() async throws -> String? {
let request = makeRequest(IPPOperation.cupsGetDefault, target: serverURI, requested: ["printer-name"])
do {
return try await send(request, resource: "/").groups(.printerAttributes).first?.string("printer-name")
} catch CupsAdminError.ipp(let status, _) where status == IPPStatus.notFound {
return nil
}
}
/// Get-Jobs. `queue` nil means all queues. `which` is "not-completed", "completed" or "all".
public func getJobs(queue: String?, which: String, requested: [String]) async throws -> [IPPGroup] {
let extra = [
IPPAttribute.string("which-jobs", .keyword, [which]),
IPPAttribute.boolean("my-jobs", false),
]
let request = makeRequest(IPPOperation.getJobs, target: queue.map(printerURI) ?? serverURI,
extra: extra, requested: requested)
return try await send(request, resource: queue.map(printerResource) ?? "/").groups(.jobAttributes)
}
public func getJobAttributes(jobID: Int, requested: [String]) async throws -> IPPGroup {
let request = makeRequest(IPPOperation.getJobAttributes, target: jobURI(jobID), requested: requested)
guard let group = try await send(request, resource: "/jobs/").groups(.jobAttributes).first else {
throw CupsAdminError.transport("cupsd returned no attributes for job \(jobID)")
}
return group
}
public func cancelJob(jobID: Int) async throws {
let request = makeRequest(IPPOperation.cancelJob, target: jobURI(jobID), requested: [])
_ = try await send(request, resource: "/jobs/")
}
/// The queue's PPD text (GET /printers/<queue>.ppd), or nil if the queue has no PPD.
public func getPPD(queue: String) async throws -> String? {
let response = try UnixSocketHTTP.get(socketPath: socketPath, resource: printerResource(queue) + ".ppd")
switch response.status {
case 200: return String(decoding: response.body, as: UTF8.self)
case 404: return nil
default: throw CupsAdminError.transport("HTTP \(response.status) fetching PPD for \(queue)")
}
}
// MARK: plumbing
private typealias Target = (name: String, uri: String)
private var serverURI: Target { ("printer-uri", "ipp://localhost/") }
private func printerURI(_ queue: String) -> Target {
("printer-uri", "ipp://localhost\(printerResource(queue))")
}
private func jobURI(_ jobID: Int) -> Target {
("job-uri", "ipp://localhost/jobs/\(jobID)")
}
private func printerResource(_ queue: String) -> String {
let escaped = queue.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed.subtracting(["/"])) ?? queue
return "/printers/\(escaped)"
}
private func makeRequest(_ operation: UInt16, target: Target, extra: [IPPAttribute] = [],
requested: [String]) -> IPPMessage {
// Order matters: charset, natural-language, target, then the rest (RFC 8011 §4.1.4).
var attributes: [IPPAttribute] = [
.string("attributes-charset", .charset, ["utf-8"]),
.string("attributes-natural-language", .naturalLanguage, ["en"]),
.string(target.name, .uri, [target.uri]),
.string("requesting-user-name", .nameWithoutLanguage, [NSUserName()]),
]
attributes += extra
if !requested.isEmpty {
attributes.append(.string("requested-attributes", .keyword, requested))
}
return IPPMessage(code: operation, groups: [IPPGroup(tag: .operationAttributes, attributes: attributes)])
}
private func send(_ message: IPPMessage, resource: String) async throws -> IPPMessage {
var message = message
message.requestID = nextRequestID
nextRequestID += 1
let response = try UnixSocketHTTP.post(socketPath: socketPath, resource: resource,
contentType: "application/ipp", body: IPPEncoder.encode(message))
guard response.status == 200 else {
throw CupsAdminError.transport("HTTP \(response.status) from cupsd for POST \(resource)")
}
let reply = try IPPDecoder.decode(response.body)
guard reply.isSuccess else {
throw CupsAdminError.ipp(status: reply.code, message: reply.operationGroup?.string("status-message"))
}
return reply
}
}

View File

@@ -0,0 +1,272 @@
import Foundation
/// RFC 8010 wire-format encoder.
enum IPPEncoder {
static func encode(_ message: IPPMessage) -> Data {
var out: [UInt8] = [message.versionMajor, message.versionMinor]
out.appendUInt16(message.code)
out.appendInt32(message.requestID)
for group in message.groups {
out.append(group.tag.rawValue)
for attribute in group.attributes {
for (index, value) in attribute.values.enumerated() {
// Additional values of a 1setOf carry an empty name.
write(value, name: index == 0 ? attribute.name : "", into: &out)
}
}
}
out.append(IPPTag.endOfAttributes.rawValue)
return Data(out) + message.data
}
private static func write(_ value: IPPValue, name: String, into out: inout [UInt8]) {
out.append(value.tag.rawValue)
out.appendLengthPrefixed(Array(name.utf8))
switch value {
case .integer(let i), .enumeration(let i):
out.appendUInt16(4)
out.appendInt32(i)
case .boolean(let b):
out.appendUInt16(1)
out.append(b ? 1 : 0)
case .string(_, let s):
out.appendLengthPrefixed(Array(s.utf8))
case .stringWithLanguage(_, let language, let s):
var payload: [UInt8] = []
payload.appendLengthPrefixed(Array(language.utf8))
payload.appendLengthPrefixed(Array(s.utf8))
out.appendLengthPrefixed(payload)
case .octetString(let d), .other(_, let d):
out.appendLengthPrefixed([UInt8](d))
case .dateTime(let date):
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(secondsFromGMT: 0)!
let c = cal.dateComponents([.year, .month, .day, .hour, .minute, .second, .nanosecond], from: date)
var payload: [UInt8] = []
payload.appendUInt16(UInt16(c.year ?? 1970))
payload += [c.month, c.day, c.hour, c.minute, c.second].map { UInt8($0 ?? 0) }
payload += [UInt8((c.nanosecond ?? 0) / 100_000_000), UInt8(ascii: "+"), 0, 0]
out.appendLengthPrefixed(payload)
case .resolution(let x, let y, let units):
out.appendUInt16(9)
out.appendInt32(x)
out.appendInt32(y)
out.append(units)
case .range(let lower, let upper):
out.appendUInt16(8)
out.appendInt32(lower)
out.appendInt32(upper)
case .outOfBand:
out.appendUInt16(0)
case .collection(let members):
out.appendUInt16(0)
for member in members {
out.append(IPPTag.memberAttrName.rawValue)
out.appendUInt16(0)
out.appendLengthPrefixed(Array(member.name.utf8))
for memberValue in member.values {
write(memberValue, name: "", into: &out)
}
}
out.append(IPPTag.endCollection.rawValue)
out.appendUInt16(0)
out.appendUInt16(0)
}
}
}
/// RFC 8010 wire-format decoder.
struct IPPDecoder {
enum DecodeError: Error, CustomStringConvertible {
case truncated(offset: Int)
case malformed(String, offset: Int)
var description: String {
switch self {
case .truncated(let offset): return "IPP response truncated at byte \(offset)"
case .malformed(let what, let offset): return "malformed IPP response at byte \(offset): \(what)"
}
}
}
private let bytes: [UInt8]
private var offset = 0
static func decode(_ data: Data) throws -> IPPMessage {
var decoder = IPPDecoder(bytes: [UInt8](data))
return try decoder.decodeMessage()
}
private init(bytes: [UInt8]) {
self.bytes = bytes
}
private mutating func decodeMessage() throws -> IPPMessage {
let major = try readUInt8()
let minor = try readUInt8()
var message = IPPMessage(code: try readUInt16())
message.versionMajor = major
message.versionMinor = minor
message.requestID = try readInt32()
var current: IPPGroup?
while true {
let tag = IPPTag(rawValue: try readUInt8())
if tag.isDelimiter {
if let group = current { message.groups.append(group) }
current = nil
if tag == .endOfAttributes { break }
current = IPPGroup(tag: tag, attributes: [])
continue
}
guard current != nil else {
throw DecodeError.malformed("value tag 0x\(String(tag.rawValue, radix: 16)) outside a group", offset: offset)
}
let name = try readString(length: Int(try readUInt16()))
let value = try readValue(tag: tag)
if name.isEmpty {
guard !current!.attributes.isEmpty else {
throw DecodeError.malformed("additional value with no preceding attribute", offset: offset)
}
current!.attributes[current!.attributes.count - 1].values.append(value)
} else {
current!.attributes.append(IPPAttribute(name: name, values: [value]))
}
}
message.data = Data(bytes[offset...])
return message
}
/// Reads value-length + value for `tag` (the name has already been consumed).
private mutating func readValue(tag: IPPTag) throws -> IPPValue {
let length = Int(try readUInt16())
if tag == .begCollection {
_ = try readBytes(length)
return .collection(try readCollectionMembers())
}
// Rebase to 0 so v[0], v[8] etc. index the value, not the whole response.
let v = Array(try readBytes(length))[...]
switch tag {
case _ where tag.isOutOfBand:
return .outOfBand(tag)
case .integer where length == 4:
return .integer(Self.int32(v, 0))
case .enumeration where length == 4:
return .enumeration(Self.int32(v, 0))
case .boolean where length == 1:
return .boolean(v[0] != 0)
case .octetString:
return .octetString(Data(v))
case .dateTime where length == 11:
return Self.date(v).map { .dateTime($0) } ?? .other(tag, Data(v))
case .resolution where length == 9:
return .resolution(x: Self.int32(v, 0), y: Self.int32(v, 4), units: v[8])
case .rangeOfInteger where length == 8:
return .range(Self.int32(v, 0), Self.int32(v, 4))
case .textWithLanguage, .nameWithLanguage:
guard length >= 4 else { return .other(tag, Data(v)) }
let langLength = Int(UInt16(v[0]) << 8 | UInt16(v[1]))
guard 2 + langLength + 2 <= length else { return .other(tag, Data(v)) }
let language = String(decoding: v[2 ..< 2 + langLength], as: UTF8.self)
let textStart = 2 + langLength
let textLength = Int(UInt16(v[textStart]) << 8 | UInt16(v[textStart + 1]))
guard textStart + 2 + textLength <= length else { return .other(tag, Data(v)) }
let text = String(decoding: v[textStart + 2 ..< textStart + 2 + textLength], as: UTF8.self)
return .stringWithLanguage(tag, language: language, text)
case _ where tag.isCharacterString:
return .string(tag, String(decoding: v, as: UTF8.self))
default:
return .other(tag, Data(v))
}
}
/// Called just after a begCollection value; consumes through the matching endCollection.
private mutating func readCollectionMembers() throws -> [IPPAttribute] {
var members: [IPPAttribute] = []
while true {
let tag = IPPTag(rawValue: try readUInt8())
guard !tag.isDelimiter else {
throw DecodeError.malformed("delimiter inside collection", offset: offset)
}
_ = try readBytes(Int(try readUInt16())) // name, always empty inside a collection
if tag == .endCollection {
_ = try readBytes(Int(try readUInt16()))
return members
}
if tag == .memberAttrName {
let memberName = try readString(length: Int(try readUInt16()))
members.append(IPPAttribute(name: memberName, values: []))
continue
}
guard !members.isEmpty else {
throw DecodeError.malformed("collection value before memberAttrName", offset: offset)
}
let value = try readValue(tag: tag)
members[members.count - 1].values.append(value)
}
}
// MARK: primitives
private mutating func readBytes(_ count: Int) throws -> ArraySlice<UInt8> {
guard offset + count <= bytes.count else { throw DecodeError.truncated(offset: offset) }
defer { offset += count }
return bytes[offset ..< offset + count]
}
private mutating func readUInt8() throws -> UInt8 { try readBytes(1).first! }
private mutating func readUInt16() throws -> UInt16 {
let b = try readBytes(2)
return UInt16(b[b.startIndex]) << 8 | UInt16(b[b.startIndex + 1])
}
private mutating func readInt32() throws -> Int32 { Self.int32(try readBytes(4), 0) }
private mutating func readString(length: Int) throws -> String {
String(decoding: try readBytes(length), as: UTF8.self)
}
private static func int32(_ b: ArraySlice<UInt8>, _ at: Int) -> Int32 {
let i = b.startIndex + at
return Int32(bitPattern: UInt32(b[i]) << 24 | UInt32(b[i + 1]) << 16 | UInt32(b[i + 2]) << 8 | UInt32(b[i + 3]))
}
/// RFC 2579 DateAndTime (11 octets).
private static func date(_ b: ArraySlice<UInt8>) -> Date? {
let s = b.startIndex
var c = DateComponents()
c.year = Int(UInt16(b[s]) << 8 | UInt16(b[s + 1]))
c.month = Int(b[s + 2])
c.day = Int(b[s + 3])
c.hour = Int(b[s + 4])
c.minute = Int(b[s + 5])
c.second = Int(b[s + 6])
c.nanosecond = Int(b[s + 7]) * 100_000_000
let sign = b[s + 8] == UInt8(ascii: "-") ? -1 : 1
let utcOffset = sign * (Int(b[s + 9]) * 3600 + Int(b[s + 10]) * 60)
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(secondsFromGMT: utcOffset) ?? TimeZone(secondsFromGMT: 0)!
return cal.date(from: c)
}
}
private extension Array where Element == UInt8 {
mutating func appendUInt16(_ v: UInt16) {
append(UInt8(v >> 8))
append(UInt8(v & 0xFF))
}
mutating func appendInt32(_ v: Int32) {
let u = UInt32(bitPattern: v)
append(contentsOf: [UInt8(u >> 24), UInt8(u >> 16 & 0xFF), UInt8(u >> 8 & 0xFF), UInt8(u & 0xFF)])
}
mutating func appendLengthPrefixed(_ payload: [UInt8]) {
appendUInt16(UInt16(payload.count))
append(contentsOf: payload)
}
}

View File

@@ -0,0 +1,164 @@
import Foundation
/// IPP tag byte (RFC 8010 §3.5). A struct rather than an enum so unknown tags survive decoding.
public struct IPPTag: RawRepresentable, Hashable {
public let rawValue: UInt8
public init(rawValue: UInt8) { self.rawValue = rawValue }
// Delimiter tags
public static let operationAttributes = IPPTag(rawValue: 0x01)
public static let jobAttributes = IPPTag(rawValue: 0x02)
public static let endOfAttributes = IPPTag(rawValue: 0x03)
public static let printerAttributes = IPPTag(rawValue: 0x04)
public static let unsupportedAttributes = IPPTag(rawValue: 0x05)
// Out-of-band values
public static let unsupported = IPPTag(rawValue: 0x10)
public static let unknown = IPPTag(rawValue: 0x12)
public static let noValue = IPPTag(rawValue: 0x13)
// Integer values
public static let integer = IPPTag(rawValue: 0x21)
public static let boolean = IPPTag(rawValue: 0x22)
public static let enumeration = IPPTag(rawValue: 0x23)
// Octet-string values
public static let octetString = IPPTag(rawValue: 0x30)
public static let dateTime = IPPTag(rawValue: 0x31)
public static let resolution = IPPTag(rawValue: 0x32)
public static let rangeOfInteger = IPPTag(rawValue: 0x33)
public static let begCollection = IPPTag(rawValue: 0x34)
public static let textWithLanguage = IPPTag(rawValue: 0x35)
public static let nameWithLanguage = IPPTag(rawValue: 0x36)
public static let endCollection = IPPTag(rawValue: 0x37)
// Character-string values
public static let textWithoutLanguage = IPPTag(rawValue: 0x41)
public static let nameWithoutLanguage = IPPTag(rawValue: 0x42)
public static let keyword = IPPTag(rawValue: 0x44)
public static let uri = IPPTag(rawValue: 0x45)
public static let uriScheme = IPPTag(rawValue: 0x46)
public static let charset = IPPTag(rawValue: 0x47)
public static let naturalLanguage = IPPTag(rawValue: 0x48)
public static let mimeMediaType = IPPTag(rawValue: 0x49)
public static let memberAttrName = IPPTag(rawValue: 0x4A)
public static let extensionTag = IPPTag(rawValue: 0x7F)
public var isDelimiter: Bool { rawValue < 0x10 }
public var isOutOfBand: Bool { (0x10...0x1F).contains(rawValue) }
public var isCharacterString: Bool { (0x40...0x5F).contains(rawValue) }
}
public enum IPPValue {
case integer(Int32)
case boolean(Bool)
case enumeration(Int32)
/// textWithoutLanguage, nameWithoutLanguage, keyword, uri, charset, mimeMediaType, ...
case string(IPPTag, String)
/// textWithLanguage / nameWithLanguage
case stringWithLanguage(IPPTag, language: String, String)
case octetString(Data)
case dateTime(Date)
case resolution(x: Int32, y: Int32, units: UInt8)
case range(Int32, Int32)
case collection([IPPAttribute])
case outOfBand(IPPTag)
case other(IPPTag, Data)
public var tag: IPPTag {
switch self {
case .integer: return .integer
case .boolean: return .boolean
case .enumeration: return .enumeration
case .string(let t, _): return t
case .stringWithLanguage(let t, _, _): return t
case .octetString: return .octetString
case .dateTime: return .dateTime
case .resolution: return .resolution
case .range: return .rangeOfInteger
case .collection: return .begCollection
case .outOfBand(let t): return t
case .other(let t, _): return t
}
}
public var stringValue: String? {
switch self {
case .string(_, let s), .stringWithLanguage(_, _, let s): return s
case .octetString(let d): return String(data: d, encoding: .utf8)
default: return nil
}
}
public var intValue: Int? {
switch self {
case .integer(let i), .enumeration(let i): return Int(i)
default: return nil
}
}
public var boolValue: Bool? {
if case .boolean(let b) = self { return b }
return nil
}
}
public struct IPPAttribute {
public var name: String
public var values: [IPPValue]
public init(name: String, values: [IPPValue]) {
self.name = name
self.values = values
}
public static func string(_ name: String, _ tag: IPPTag, _ values: [String]) -> IPPAttribute {
IPPAttribute(name: name, values: values.map { .string(tag, $0) })
}
public static func boolean(_ name: String, _ value: Bool) -> IPPAttribute {
IPPAttribute(name: name, values: [.boolean(value)])
}
}
/// One attribute group (operation, printer, job, ...) in a message.
public struct IPPGroup {
public var tag: IPPTag
public var attributes: [IPPAttribute]
public subscript(name: String) -> IPPAttribute? {
attributes.first { $0.name == name }
}
public func string(_ name: String) -> String? { self[name]?.values.first?.stringValue }
public func strings(_ name: String) -> [String] { self[name]?.values.compactMap(\.stringValue) ?? [] }
public func int(_ name: String) -> Int? { self[name]?.values.first?.intValue }
public func ints(_ name: String) -> [Int] { self[name]?.values.compactMap(\.intValue) ?? [] }
public func bool(_ name: String) -> Bool? { self[name]?.values.first?.boolValue }
}
public struct IPPMessage {
public var versionMajor: UInt8 = 2
public var versionMinor: UInt8 = 0
/// operation-id in a request, status-code in a response
public var code: UInt16
public var requestID: Int32 = 1
public var groups: [IPPGroup] = []
public var data = Data()
/// Status codes 0x0000-0x00FF are successful-ok-*.
public var isSuccess: Bool { code < 0x0100 }
public func groups(_ tag: IPPTag) -> [IPPGroup] { groups.filter { $0.tag == tag } }
public var operationGroup: IPPGroup? { groups.first { $0.tag == .operationAttributes } }
}
public enum IPPOperation {
public static let cancelJob: UInt16 = 0x0008
public static let getJobAttributes: UInt16 = 0x0009
public static let getJobs: UInt16 = 0x000A
public static let getPrinterAttributes: UInt16 = 0x000B
public static let cupsGetDefault: UInt16 = 0x4001
public static let cupsGetPrinters: UInt16 = 0x4002
}

View File

@@ -0,0 +1,175 @@
import Foundation
/// Minimal HTTP/1.1 client (POST for IPP, GET for PPDs) over a Unix domain socket.
///
/// Why not URLSession to localhost:631: on macOS 27 launchd only socket-activates cupsd on
/// /private/var/run/cupsd. TCP 631 is bound by cupsd itself and disappears when cupsd idle-exits,
/// so connecting over TCP fails whenever the scheduler is asleep. URLSession can't do AF_UNIX.
enum UnixSocketHTTP {
struct Response {
let status: Int
let body: Data
}
static func post(socketPath: String, resource: String, contentType: String, body: Data) throws -> Response {
try request(method: "POST", socketPath: socketPath, resource: resource, contentType: contentType, body: body)
}
static func get(socketPath: String, resource: String) throws -> Response {
try request(method: "GET", socketPath: socketPath, resource: resource, contentType: nil, body: Data())
}
private static func request(method: String, socketPath: String, resource: String, contentType: String?,
body: Data, timeoutSeconds: Int = 15) throws -> Response {
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { throw failure("socket()") }
defer { close(fd) }
var timeout = timeval(tv_sec: timeoutSeconds, tv_usec: 0)
let timevalSize = socklen_t(MemoryLayout<timeval>.size)
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, timevalSize)
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, timevalSize)
var noSigPipe: Int32 = 1
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, socklen_t(MemoryLayout<Int32>.size))
var address = sockaddr_un()
address.sun_family = sa_family_t(AF_UNIX)
let path = Array(socketPath.utf8) + [0]
let capacity = MemoryLayout.size(ofValue: address.sun_path)
guard path.count <= capacity else { throw CupsAdminError.transport("socket path too long: \(socketPath)") }
withUnsafeMutableBytes(of: &address.sun_path) { $0.copyBytes(from: path) }
address.sun_len = UInt8(MemoryLayout<sockaddr_un>.size)
let connected = withUnsafePointer(to: &address) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
connect(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
guard connected == 0 else { throw failure("connect to \(socketPath)") }
var head = "\(method) \(resource) HTTP/1.1\r\nHost: localhost\r\n"
if let contentType {
head += "Content-Type: \(contentType)\r\nContent-Length: \(body.count)\r\n"
}
head += "Connection: close\r\n\r\n"
try writeAll(fd, [UInt8](Data(head.utf8) + body))
var reader = Reader(fd: fd)
let statusLine = try reader.readLine()
let statusParts = statusLine.split(separator: " ", maxSplits: 2)
guard statusParts.count >= 2, statusParts[0].hasPrefix("HTTP/"), let status = Int(statusParts[1]) else {
throw CupsAdminError.transport("bad HTTP status line from cupsd: \(statusLine)")
}
var headers: [String: String] = [:]
while true {
let line = try reader.readLine()
if line.isEmpty { break }
guard let colon = line.firstIndex(of: ":") else { continue }
let name = line[..<colon].lowercased()
headers[name] = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
}
let payload: [UInt8]
if headers["transfer-encoding"]?.lowercased().contains("chunked") == true {
var chunks: [UInt8] = []
while true {
let sizeLine = try reader.readLine()
let hex = sizeLine.split(separator: ";").first.map(String.init)?.trimmingCharacters(in: .whitespaces) ?? ""
guard let size = Int(hex, radix: 16) else {
throw CupsAdminError.transport("bad chunk size from cupsd: \(sizeLine)")
}
if size == 0 {
while try !reader.readLine().isEmpty {} // trailers
break
}
chunks += try reader.readBytes(size)
_ = try reader.readLine()
}
payload = chunks
} else if let length = headers["content-length"].flatMap(Int.init) {
payload = try reader.readBytes(length)
} else {
payload = try reader.readToEnd()
}
return Response(status: status, body: Data(payload))
}
private static func writeAll(_ fd: Int32, _ bytes: [UInt8]) throws {
var sent = 0
while sent < bytes.count {
let n = bytes[sent...].withUnsafeBytes { write(fd, $0.baseAddress, $0.count) }
if n < 0 {
if errno == EINTR { continue }
throw failure("write to cupsd")
}
sent += n
}
}
fileprivate static func failure(_ what: String) -> CupsAdminError {
let code = errno
let reason: String
switch code {
case ENOENT: reason = "socket not found (is CUPS installed?)"
case ECONNREFUSED: reason = "connection refused (cupsd not running?)"
case EAGAIN: reason = "timed out"
default: reason = String(cString: strerror(code))
}
return CupsAdminError.transport("\(what): \(reason)")
}
private struct Reader {
let fd: Int32
private var buffer: [UInt8] = []
private var position = 0
init(fd: Int32) { self.fd = fd }
/// Returns false at EOF.
private mutating func fill() throws -> Bool {
if position > 0 {
buffer.removeFirst(position)
position = 0
}
var chunk = [UInt8](repeating: 0, count: 65_536)
while true {
let n = chunk.withUnsafeMutableBytes { read(fd, $0.baseAddress, $0.count) }
if n < 0 {
if errno == EINTR { continue }
throw UnixSocketHTTP.failure("read from cupsd")
}
if n == 0 { return false }
buffer += chunk[0 ..< n]
return true
}
}
mutating func readLine() throws -> String {
while true {
if let lf = buffer[position...].firstIndex(of: UInt8(ascii: "\n")) {
var end = lf
if end > position, buffer[end - 1] == UInt8(ascii: "\r") { end -= 1 }
let line = String(decoding: buffer[position ..< end], as: UTF8.self)
position = lf + 1
return line
}
guard try fill() else { throw CupsAdminError.transport("cupsd closed the connection mid-response") }
}
}
mutating func readBytes(_ count: Int) throws -> [UInt8] {
while buffer.count - position < count {
guard try fill() else { throw CupsAdminError.transport("cupsd closed the connection mid-response") }
}
defer { position += count }
return Array(buffer[position ..< position + count])
}
mutating func readToEnd() throws -> [UInt8] {
while try fill() {}
defer { position = buffer.count }
return Array(buffer[position...])
}
}
}