From 1d5526019bcba9b3a7bb95e442578f8e701c2416 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:43:35 +0100 Subject: [PATCH 01/47] feat(state): parse battery clamshell setting --- .swift-format | 6 ++ Package.swift | 2 +- Sources/ClamshellCore/ClamshellError.swift | 3 + Sources/ClamshellCore/ClamshellState.swift | 4 ++ .../ClamshellCore/PowerSettingsParser.swift | 49 +++++++++++++ .../BuildVersionTests.swift | 1 + .../PowerSettingsParserTests.swift | 68 +++++++++++++++++++ 7 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 .swift-format create mode 100644 Sources/ClamshellCore/ClamshellError.swift create mode 100644 Sources/ClamshellCore/ClamshellState.swift create mode 100644 Sources/ClamshellCore/PowerSettingsParser.swift create mode 100644 Tests/ClamshellCoreTests/PowerSettingsParserTests.swift diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..1e26525 --- /dev/null +++ b/.swift-format @@ -0,0 +1,6 @@ +{ + "indentation": { + "spaces": 4 + }, + "version": 1 +} diff --git a/Package.swift b/Package.swift index c6ad085..76e94fd 100644 --- a/Package.swift +++ b/Package.swift @@ -13,7 +13,7 @@ let package = Package( .package( url: "https://github.com/apple/swift-argument-parser", from: "1.8.0" - ), + ) ], targets: [ .target(name: "ClamshellCore"), diff --git a/Sources/ClamshellCore/ClamshellError.swift b/Sources/ClamshellCore/ClamshellError.swift new file mode 100644 index 0000000..2c3c31e --- /dev/null +++ b/Sources/ClamshellCore/ClamshellError.swift @@ -0,0 +1,3 @@ +public enum ClamshellError: Error, Equatable { + case unrecognisedPowerSettings +} diff --git a/Sources/ClamshellCore/ClamshellState.swift b/Sources/ClamshellCore/ClamshellState.swift new file mode 100644 index 0000000..3e451f6 --- /dev/null +++ b/Sources/ClamshellCore/ClamshellState.swift @@ -0,0 +1,4 @@ +public enum ClamshellState: String, Sendable { + case enabled + case disabled +} diff --git a/Sources/ClamshellCore/PowerSettingsParser.swift b/Sources/ClamshellCore/PowerSettingsParser.swift new file mode 100644 index 0000000..3d97095 --- /dev/null +++ b/Sources/ClamshellCore/PowerSettingsParser.swift @@ -0,0 +1,49 @@ +import Foundation + +public struct PowerSettingsParser: Sendable { + public init() {} + + public func batteryState(from output: String) throws -> ClamshellState { + let lines = output.split( + omittingEmptySubsequences: false, + whereSeparator: \Character.isNewline + ) + + guard + let batteryHeader = lines.firstIndex(where: { + $0.trimmingCharacters(in: .whitespaces) == "Battery Power:" + }) + else { + throw ClamshellError.unrecognisedPowerSettings + } + + for line in lines[lines.index(after: batteryHeader)...] { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { + continue + } + guard line.first?.isWhitespace == true else { + break + } + + let fields = trimmed.split(whereSeparator: \Character.isWhitespace) + guard fields.first == "disablesleep" else { + continue + } + guard fields.count == 2 else { + throw ClamshellError.unrecognisedPowerSettings + } + + switch fields[1] { + case "1": + return .enabled + case "0": + return .disabled + default: + throw ClamshellError.unrecognisedPowerSettings + } + } + + return .disabled + } +} diff --git a/Tests/ClamshellCoreTests/BuildVersionTests.swift b/Tests/ClamshellCoreTests/BuildVersionTests.swift index 3094c58..087fe90 100644 --- a/Tests/ClamshellCoreTests/BuildVersionTests.swift +++ b/Tests/ClamshellCoreTests/BuildVersionTests.swift @@ -1,4 +1,5 @@ import Testing + @testable import ClamshellCore @Suite("Build version") diff --git a/Tests/ClamshellCoreTests/PowerSettingsParserTests.swift b/Tests/ClamshellCoreTests/PowerSettingsParserTests.swift new file mode 100644 index 0000000..01ae636 --- /dev/null +++ b/Tests/ClamshellCoreTests/PowerSettingsParserTests.swift @@ -0,0 +1,68 @@ +import Testing + +@testable import ClamshellCore + +@Suite("pmset parser") +struct PowerSettingsParserTests { + @Test("reads enabled from Battery Power") + func enabled() throws { + let output = """ + Battery Power: + sleep 1 + disablesleep 1 + AC Power: + sleep 1 + disablesleep 0 + """ + + #expect(try PowerSettingsParser().batteryState(from: output) == .enabled) + } + + @Test("reads disabled from Battery Power") + func disabled() throws { + let output = """ + Battery Power: + sleep 1 + disablesleep 0 + AC Power: + disablesleep 1 + """ + + #expect(try PowerSettingsParser().batteryState(from: output) == .disabled) + } + + @Test("treats an absent battery disablesleep key as disabled") + func absentMeansDisabled() throws { + let output = """ + Battery Power: + sleep 1 + AC Power: + disablesleep 1 + """ + + #expect(try PowerSettingsParser().batteryState(from: output) == .disabled) + } + + @Test("rejects output without a Battery Power section") + func missingBatterySection() { + #expect(throws: ClamshellError.self) { + try PowerSettingsParser().batteryState( + from: "AC Power:\n disablesleep 1" + ) + } + } + + @Test("rejects an unexpected battery disablesleep value") + func unexpectedValue() { + let output = """ + Battery Power: + disablesleep 2 + AC Power: + disablesleep 0 + """ + + #expect(throws: ClamshellError.self) { + try PowerSettingsParser().batteryState(from: output) + } + } +} From 756156aad10e4dbcdaec49807b6c449c7c1483c5 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:47:12 +0100 Subject: [PATCH 02/47] feat(status): report battery clamshell state --- Sources/ClamshellCLI/ClamshellCommand.swift | 4 +- .../ClamshellCLI/Commands/StatusCommand.swift | 15 +++ Sources/ClamshellCLI/Console.swift | 13 ++ Sources/ClamshellCore/ClamshellError.swift | 6 + .../ClamshellCore/PowerSettingsClient.swift | 27 +++++ Sources/ClamshellCore/ProcessRunner.swift | 112 ++++++++++++++++++ .../PowerSettingsClientTests.swift | 105 ++++++++++++++++ 7 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 Sources/ClamshellCLI/Commands/StatusCommand.swift create mode 100644 Sources/ClamshellCLI/Console.swift create mode 100644 Sources/ClamshellCore/PowerSettingsClient.swift create mode 100644 Sources/ClamshellCore/ProcessRunner.swift create mode 100644 Tests/ClamshellCoreTests/PowerSettingsClientTests.swift diff --git a/Sources/ClamshellCLI/ClamshellCommand.swift b/Sources/ClamshellCLI/ClamshellCommand.swift index c0e94a2..5f0296c 100644 --- a/Sources/ClamshellCLI/ClamshellCommand.swift +++ b/Sources/ClamshellCLI/ClamshellCommand.swift @@ -6,6 +6,8 @@ struct ClamshellCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "clamshellctl", abstract: "Control battery clamshell mode on macOS.", - version: BuildVersion.current + version: BuildVersion.current, + subcommands: [StatusCommand.self], + defaultSubcommand: StatusCommand.self ) } diff --git a/Sources/ClamshellCLI/Commands/StatusCommand.swift b/Sources/ClamshellCLI/Commands/StatusCommand.swift new file mode 100644 index 0000000..75ee953 --- /dev/null +++ b/Sources/ClamshellCLI/Commands/StatusCommand.swift @@ -0,0 +1,15 @@ +import ArgumentParser +import ClamshellCore + +struct StatusCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "status", + abstract: "Show the current battery clamshell mode." + ) + + func run() throws { + let client = PowerSettingsClient(runner: FoundationProcessRunner()) + let state = try client.currentState() + Console().writeLine("Battery clamshell mode: \(state.rawValue)") + } +} diff --git a/Sources/ClamshellCLI/Console.swift b/Sources/ClamshellCLI/Console.swift new file mode 100644 index 0000000..341d006 --- /dev/null +++ b/Sources/ClamshellCLI/Console.swift @@ -0,0 +1,13 @@ +import Foundation + +struct Console { + private let standardOutput: FileHandle + + init(standardOutput: FileHandle = .standardOutput) { + self.standardOutput = standardOutput + } + + func writeLine(_ line: String) { + standardOutput.write(Data("\(line)\n".utf8)) + } +} diff --git a/Sources/ClamshellCore/ClamshellError.swift b/Sources/ClamshellCore/ClamshellError.swift index 2c3c31e..4754581 100644 --- a/Sources/ClamshellCore/ClamshellError.swift +++ b/Sources/ClamshellCore/ClamshellError.swift @@ -1,3 +1,9 @@ public enum ClamshellError: Error, Equatable { + case invalidProcessOutput(executable: String, stream: ProcessOutputStream) + case processFailed( + executable: String, + terminationStatus: Int32, + standardError: String + ) case unrecognisedPowerSettings } diff --git a/Sources/ClamshellCore/PowerSettingsClient.swift b/Sources/ClamshellCore/PowerSettingsClient.swift new file mode 100644 index 0000000..fe3270e --- /dev/null +++ b/Sources/ClamshellCore/PowerSettingsClient.swift @@ -0,0 +1,27 @@ +public struct PowerSettingsClient: Sendable { + private let runner: any ProcessRunning + private let parser: PowerSettingsParser + + public init( + runner: any ProcessRunning, + parser: PowerSettingsParser = PowerSettingsParser() + ) { + self.runner = runner + self.parser = parser + } + + public func currentState() throws -> ClamshellState { + let executable = "/usr/bin/pmset" + let result = try runner.run(executable, arguments: ["-g", "custom"]) + + guard result.terminationStatus == 0 else { + throw ClamshellError.processFailed( + executable: executable, + terminationStatus: result.terminationStatus, + standardError: result.standardError + ) + } + + return try parser.batteryState(from: result.standardOutput) + } +} diff --git a/Sources/ClamshellCore/ProcessRunner.swift b/Sources/ClamshellCore/ProcessRunner.swift new file mode 100644 index 0000000..7653989 --- /dev/null +++ b/Sources/ClamshellCore/ProcessRunner.swift @@ -0,0 +1,112 @@ +import Dispatch +import Foundation + +public enum ProcessOutputStream: String, Sendable { + case standardOutput + case standardError +} + +public struct ProcessResult: Sendable, Equatable { + public let standardOutput: String + public let standardError: String + public let terminationStatus: Int32 + + public init( + standardOutput: String, + standardError: String, + terminationStatus: Int32 + ) { + self.standardOutput = standardOutput + self.standardError = standardError + self.terminationStatus = terminationStatus + } +} + +public protocol ProcessRunning: Sendable { + func run(_ executable: String, arguments: [String]) throws -> ProcessResult +} + +public struct FoundationProcessRunner: ProcessRunning { + public init() {} + + public func run(_ executable: String, arguments: [String]) throws -> ProcessResult { + let process = Process() + let standardOutput = Pipe() + let standardError = Pipe() + + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + process.standardOutput = standardOutput + process.standardError = standardError + + try process.run() + + let collector = ProcessOutputCollector( + standardOutput: standardOutput.fileHandleForReading, + standardError: standardError.fileHandleForReading + ) + collector.start() + process.waitUntilExit() + + let output = collector.waitForOutput() + guard let outputString = String(data: output.standardOutput, encoding: .utf8) else { + throw ClamshellError.invalidProcessOutput( + executable: executable, + stream: .standardOutput + ) + } + guard let errorString = String(data: output.standardError, encoding: .utf8) else { + throw ClamshellError.invalidProcessOutput( + executable: executable, + stream: .standardError + ) + } + + return ProcessResult( + standardOutput: outputString, + standardError: errorString, + terminationStatus: process.terminationStatus + ) + } +} + +private final class ProcessOutputCollector: @unchecked Sendable { + private let standardOutput: FileHandle + private let standardError: FileHandle + private let group = DispatchGroup() + private let lock = NSLock() + private var outputData = Data() + private var errorData = Data() + + init(standardOutput: FileHandle, standardError: FileHandle) { + self.standardOutput = standardOutput + self.standardError = standardError + } + + func start() { + group.enter() + DispatchQueue.global(qos: .utility).async { + let data = self.standardOutput.readDataToEndOfFile() + self.lock.withLock { + self.outputData = data + } + self.group.leave() + } + + group.enter() + DispatchQueue.global(qos: .utility).async { + let data = self.standardError.readDataToEndOfFile() + self.lock.withLock { + self.errorData = data + } + self.group.leave() + } + } + + func waitForOutput() -> (standardOutput: Data, standardError: Data) { + group.wait() + return lock.withLock { + (outputData, errorData) + } + } +} diff --git a/Tests/ClamshellCoreTests/PowerSettingsClientTests.swift b/Tests/ClamshellCoreTests/PowerSettingsClientTests.swift new file mode 100644 index 0000000..f20d3ac --- /dev/null +++ b/Tests/ClamshellCoreTests/PowerSettingsClientTests.swift @@ -0,0 +1,105 @@ +import Foundation +import Testing + +@testable import ClamshellCore + +@Suite("Power settings client") +struct PowerSettingsClientTests { + @Test("reads the battery state with pmset custom settings") + func currentState() throws { + let runner = RecordingProcessRunner( + result: ProcessResult( + standardOutput: """ + Battery Power: + disablesleep 1 + AC Power: + disablesleep 0 + """, + standardError: "", + terminationStatus: 0 + ) + ) + + let state = try PowerSettingsClient(runner: runner).currentState() + + #expect(state == .enabled) + #expect( + runner.invocations == [ + ProcessInvocation( + executable: "/usr/bin/pmset", + arguments: ["-g", "custom"] + ) + ]) + } + + @Test("preserves a failed pmset exit status and stderr") + func processFailure() { + let runner = RecordingProcessRunner( + result: ProcessResult( + standardOutput: "", + standardError: "pmset failed", + terminationStatus: 64 + ) + ) + + #expect( + throws: ClamshellError.processFailed( + executable: "/usr/bin/pmset", + terminationStatus: 64, + standardError: "pmset failed" + ) + ) { + try PowerSettingsClient(runner: runner).currentState() + } + } +} + +@Suite("Foundation process runner") +struct FoundationProcessRunnerTests { + @Test("captures separate output streams and the exit status") + func capturesProcessResult() throws { + let result = try FoundationProcessRunner().run( + "/bin/sh", + arguments: ["-c", "printf output; printf error >&2; exit 7"] + ) + + #expect( + result + == ProcessResult( + standardOutput: "output", + standardError: "error", + terminationStatus: 7 + ) + ) + } +} + +private struct ProcessInvocation: Equatable { + let executable: String + let arguments: [String] +} + +private final class RecordingProcessRunner: ProcessRunning, @unchecked Sendable { + private let lock = NSLock() + private let result: ProcessResult + private var recordedInvocations: [ProcessInvocation] = [] + + init(result: ProcessResult) { + self.result = result + } + + var invocations: [ProcessInvocation] { + lock.withLock { + recordedInvocations + } + } + + func run(_ executable: String, arguments: [String]) throws -> ProcessResult { + lock.withLock { + recordedInvocations.append( + ProcessInvocation(executable: executable, arguments: arguments) + ) + } + return result + } +} From d9a2b54b7a67e388cf87727df4f74007cfbcfa64 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:48:38 +0100 Subject: [PATCH 03/47] feat(control): add verified state transitions --- Sources/ClamshellCore/ClamshellError.swift | 1 + Sources/ClamshellCore/ClamshellService.swift | 71 +++++++++++ .../ClamshellServiceTests.swift | 113 ++++++++++++++++++ 3 files changed, 185 insertions(+) create mode 100644 Sources/ClamshellCore/ClamshellService.swift create mode 100644 Tests/ClamshellCoreTests/ClamshellServiceTests.swift diff --git a/Sources/ClamshellCore/ClamshellError.swift b/Sources/ClamshellCore/ClamshellError.swift index 4754581..bee4bd2 100644 --- a/Sources/ClamshellCore/ClamshellError.swift +++ b/Sources/ClamshellCore/ClamshellError.swift @@ -5,5 +5,6 @@ public enum ClamshellError: Error, Equatable { terminationStatus: Int32, standardError: String ) + case stateVerificationFailed(expected: ClamshellState, actual: ClamshellState) case unrecognisedPowerSettings } diff --git a/Sources/ClamshellCore/ClamshellService.swift b/Sources/ClamshellCore/ClamshellService.swift new file mode 100644 index 0000000..75dc817 --- /dev/null +++ b/Sources/ClamshellCore/ClamshellService.swift @@ -0,0 +1,71 @@ +public protocol PowerStateReading: Sendable { + func currentState() throws -> ClamshellState +} + +public protocol PowerStateWriting: Sendable { + func setState(_ state: ClamshellState) throws +} + +public struct TransitionResult: Sendable, Equatable { + public let previous: ClamshellState + public let current: ClamshellState + public let didChange: Bool + + public init(previous: ClamshellState, current: ClamshellState, didChange: Bool) { + self.previous = previous + self.current = current + self.didChange = didChange + } +} + +public struct ClamshellService: Sendable { + private let stateReader: any PowerStateReading + private let stateWriter: any PowerStateWriting + + public init( + stateReader: any PowerStateReading, + stateWriter: any PowerStateWriting + ) { + self.stateReader = stateReader + self.stateWriter = stateWriter + } + + public func set(_ requested: ClamshellState) throws -> TransitionResult { + let current = try stateReader.currentState() + return try set(requested, from: current) + } + + public func toggle() throws -> TransitionResult { + let current = try stateReader.currentState() + let requested: ClamshellState = current == .enabled ? .disabled : .enabled + return try set(requested, from: current) + } + + private func set( + _ requested: ClamshellState, + from previous: ClamshellState + ) throws -> TransitionResult { + guard previous != requested else { + return TransitionResult( + previous: previous, + current: previous, + didChange: false + ) + } + + try stateWriter.setState(requested) + let current = try stateReader.currentState() + guard current == requested else { + throw ClamshellError.stateVerificationFailed( + expected: requested, + actual: current + ) + } + + return TransitionResult( + previous: previous, + current: current, + didChange: true + ) + } +} diff --git a/Tests/ClamshellCoreTests/ClamshellServiceTests.swift b/Tests/ClamshellCoreTests/ClamshellServiceTests.swift new file mode 100644 index 0000000..ef0be7b --- /dev/null +++ b/Tests/ClamshellCoreTests/ClamshellServiceTests.swift @@ -0,0 +1,113 @@ +import Foundation +import Testing + +@testable import ClamshellCore + +@Suite("Clamshell service") +struct ClamshellServiceTests { + @Test( + "applies only required state transitions", + arguments: [ + (ClamshellState.disabled, ClamshellState.disabled, false), + (.disabled, .enabled, true), + (.enabled, .enabled, false), + (.enabled, .disabled, true), + ] + ) + func transition( + current: ClamshellState, + requested: ClamshellState, + shouldMutate: Bool + ) throws { + let power = RecordingPowerSettings(current: current) + let service = ClamshellService(stateReader: power, stateWriter: power) + + let result = try service.set(requested) + + #expect( + result + == TransitionResult( + previous: current, + current: requested, + didChange: shouldMutate + ) + ) + #expect(power.requestedStates == (shouldMutate ? [requested] : [])) + } + + @Test("rejects a mutation that does not reach the requested state") + func verificationFailure() { + let power = RecordingPowerSettings( + current: .disabled, + stateAfterMutation: .disabled + ) + let service = ClamshellService(stateReader: power, stateWriter: power) + + #expect( + throws: ClamshellError.stateVerificationFailed( + expected: .enabled, + actual: .disabled + ) + ) { + try service.set(.enabled) + } + } + + @Test("toggles from a fresh state and verifies the result") + func toggle() throws { + let power = RecordingPowerSettings(current: .enabled) + let service = ClamshellService(stateReader: power, stateWriter: power) + + let result = try service.toggle() + + #expect( + result + == TransitionResult( + previous: .enabled, + current: .disabled, + didChange: true + ) + ) + #expect(power.requestedStates == [.disabled]) + #expect(power.readCount == 2) + } +} + +private final class RecordingPowerSettings: + PowerStateReading, + PowerStateWriting, + @unchecked Sendable +{ + private let lock = NSLock() + private let stateAfterMutation: ClamshellState? + private var state: ClamshellState + private var recordedStates: [ClamshellState] = [] + private var recordedReadCount = 0 + + init(current: ClamshellState, stateAfterMutation: ClamshellState? = nil) { + state = current + self.stateAfterMutation = stateAfterMutation + } + + var requestedStates: [ClamshellState] { + lock.withLock { recordedStates } + } + + var readCount: Int { + lock.withLock { recordedReadCount } + } + + func currentState() throws -> ClamshellState { + lock.withLock { + recordedReadCount += 1 + return state + } + } + + func setState(_ requested: ClamshellState) throws { + lock.withLock { + recordedStates.append(requested) + state = stateAfterMutation ?? requested + } + } +} From 7c5cfebacb640896c31aea18b430b8cc0e15250e Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:50:55 +0100 Subject: [PATCH 04/47] feat(helper): restrict privileged power mutations --- Sources/ClamshellCore/ClamshellError.swift | 1 + .../ClamshellCore/PowerSettingsClient.swift | 32 ++++- Sources/ClamshellHelper/ClamshellHelper.swift | 27 ++++- .../PowerMutationTests.swift | 112 ++++++++++++++++++ 4 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 Tests/ClamshellCoreTests/PowerMutationTests.swift diff --git a/Sources/ClamshellCore/ClamshellError.swift b/Sources/ClamshellCore/ClamshellError.swift index bee4bd2..cbc8822 100644 --- a/Sources/ClamshellCore/ClamshellError.swift +++ b/Sources/ClamshellCore/ClamshellError.swift @@ -1,4 +1,5 @@ public enum ClamshellError: Error, Equatable { + case invalidHelperArguments case invalidProcessOutput(executable: String, stream: ProcessOutputStream) case processFailed( executable: String, diff --git a/Sources/ClamshellCore/PowerSettingsClient.swift b/Sources/ClamshellCore/PowerSettingsClient.swift index fe3270e..94ba45d 100644 --- a/Sources/ClamshellCore/PowerSettingsClient.swift +++ b/Sources/ClamshellCore/PowerSettingsClient.swift @@ -1,4 +1,19 @@ -public struct PowerSettingsClient: Sendable { +public struct PowerMutation: Sendable, Equatable { + public let state: ClamshellState + + public init(rawArguments: [String]) throws { + switch rawArguments { + case ["enable"]: + state = .enabled + case ["disable"]: + state = .disabled + default: + throw ClamshellError.invalidHelperArguments + } + } +} + +public struct PowerSettingsClient: PowerStateReading, PowerStateWriting, Sendable { private let runner: any ProcessRunning private let parser: PowerSettingsParser @@ -11,8 +26,19 @@ public struct PowerSettingsClient: Sendable { } public func currentState() throws -> ClamshellState { + let result = try runPmset(arguments: ["-g", "custom"]) + + return try parser.batteryState(from: result.standardOutput) + } + + public func setState(_ state: ClamshellState) throws { + let value = state == .enabled ? "1" : "0" + _ = try runPmset(arguments: ["-b", "disablesleep", value]) + } + + private func runPmset(arguments: [String]) throws -> ProcessResult { let executable = "/usr/bin/pmset" - let result = try runner.run(executable, arguments: ["-g", "custom"]) + let result = try runner.run(executable, arguments: arguments) guard result.terminationStatus == 0 else { throw ClamshellError.processFailed( @@ -22,6 +48,6 @@ public struct PowerSettingsClient: Sendable { ) } - return try parser.batteryState(from: result.standardOutput) + return result } } diff --git a/Sources/ClamshellHelper/ClamshellHelper.swift b/Sources/ClamshellHelper/ClamshellHelper.swift index 4fc3e0a..ef5db1c 100644 --- a/Sources/ClamshellHelper/ClamshellHelper.swift +++ b/Sources/ClamshellHelper/ClamshellHelper.swift @@ -1,10 +1,33 @@ +import ClamshellCore import Darwin import Foundation @main enum ClamshellHelper { static func main() { - FileHandle.standardError.write(Data("Invalid helper invocation.\n".utf8)) - exit(EX_USAGE) + guard geteuid() == 0 else { + fail("Administrator privileges are required.", exitCode: EX_NOPERM) + } + + do { + let mutation = try PowerMutation( + rawArguments: Array(CommandLine.arguments.dropFirst()) + ) + let powerSettings = PowerSettingsClient(runner: FoundationProcessRunner()) + let service = ClamshellService( + stateReader: powerSettings, + stateWriter: powerSettings + ) + _ = try service.set(mutation.state) + } catch ClamshellError.invalidHelperArguments { + fail("Usage: clamshellctl-helper ", exitCode: EX_USAGE) + } catch { + fail("Unable to update battery clamshell mode.", exitCode: EX_SOFTWARE) + } + } + + private static func fail(_ message: String, exitCode: Int32) -> Never { + FileHandle.standardError.write(Data("\(message)\n".utf8)) + exit(exitCode) } } diff --git a/Tests/ClamshellCoreTests/PowerMutationTests.swift b/Tests/ClamshellCoreTests/PowerMutationTests.swift new file mode 100644 index 0000000..9a1d36b --- /dev/null +++ b/Tests/ClamshellCoreTests/PowerMutationTests.swift @@ -0,0 +1,112 @@ +import Foundation +import Testing + +@testable import ClamshellCore + +@Suite("Privileged power mutation") +struct PowerMutationTests { + @Test( + "accepts one exact helper action", + arguments: [ + (["enable"], ClamshellState.enabled), + (["disable"], ClamshellState.disabled), + ] + ) + func acceptedArguments(arguments: [String], expectedState: ClamshellState) throws { + #expect(try PowerMutation(rawArguments: arguments).state == expectedState) + } + + @Test("rejects every other helper argument shape") + func rejectedArguments() { + let invalidArguments = [ + [], + ["enable", "disable"], + ["--enable"], + ["Enable"], + ["1"], + ["enable", "extra"], + ] + + for arguments in invalidArguments { + #expect(throws: ClamshellError.invalidHelperArguments) { + try PowerMutation(rawArguments: arguments) + } + } + } + + @Test( + "maps states to battery-only pmset arguments", + arguments: [ + (ClamshellState.enabled, "1"), + (.disabled, "0"), + ] + ) + func pmsetArguments(state: ClamshellState, value: String) throws { + let runner = MutationRecordingRunner( + result: ProcessResult( + standardOutput: "", + standardError: "", + terminationStatus: 0 + ) + ) + + try PowerSettingsClient(runner: runner).setState(state) + + #expect( + runner.invocations == [ + MutationInvocation( + executable: "/usr/bin/pmset", + arguments: ["-b", "disablesleep", value] + ) + ]) + } + + @Test("preserves a failed mutation exit status and stderr") + func mutationFailure() { + let runner = MutationRecordingRunner( + result: ProcessResult( + standardOutput: "", + standardError: "permission denied", + terminationStatus: 77 + ) + ) + + #expect( + throws: ClamshellError.processFailed( + executable: "/usr/bin/pmset", + terminationStatus: 77, + standardError: "permission denied" + ) + ) { + try PowerSettingsClient(runner: runner).setState(.enabled) + } + } +} + +private struct MutationInvocation: Equatable { + let executable: String + let arguments: [String] +} + +private final class MutationRecordingRunner: ProcessRunning, @unchecked Sendable { + private let lock = NSLock() + private let result: ProcessResult + private var recordedInvocations: [MutationInvocation] = [] + + init(result: ProcessResult) { + self.result = result + } + + var invocations: [MutationInvocation] { + lock.withLock { recordedInvocations } + } + + func run(_ executable: String, arguments: [String]) throws -> ProcessResult { + lock.withLock { + recordedInvocations.append( + MutationInvocation(executable: executable, arguments: arguments) + ) + } + return result + } +} From dbf68e009d1c0e2e5648386d503d74295aa841e3 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:54:07 +0100 Subject: [PATCH 05/47] feat(cli): add clamshell control commands --- Sources/ClamshellCLI/ClamshellCommand.swift | 17 +++- .../Commands/DisableCommand.swift | 15 ++++ .../ClamshellCLI/Commands/EnableCommand.swift | 15 ++++ .../ClamshellCLI/Commands/StatusCommand.swift | 6 +- .../ClamshellCLI/Commands/ToggleCommand.swift | 15 ++++ Sources/ClamshellCLI/Console.swift | 19 ++++- Sources/ClamshellCore/ClamshellError.swift | 36 +++++++- .../PrivilegedHelperClient.swift | 26 ++++++ .../PrivilegedHelperClientTests.swift | 85 +++++++++++++++++++ 9 files changed, 230 insertions(+), 4 deletions(-) create mode 100644 Sources/ClamshellCLI/Commands/DisableCommand.swift create mode 100644 Sources/ClamshellCLI/Commands/EnableCommand.swift create mode 100644 Sources/ClamshellCLI/Commands/ToggleCommand.swift create mode 100644 Sources/ClamshellCore/PrivilegedHelperClient.swift create mode 100644 Tests/ClamshellCoreTests/PrivilegedHelperClientTests.swift diff --git a/Sources/ClamshellCLI/ClamshellCommand.swift b/Sources/ClamshellCLI/ClamshellCommand.swift index 5f0296c..026a670 100644 --- a/Sources/ClamshellCLI/ClamshellCommand.swift +++ b/Sources/ClamshellCLI/ClamshellCommand.swift @@ -7,7 +7,22 @@ struct ClamshellCommand: ParsableCommand { commandName: "clamshellctl", abstract: "Control battery clamshell mode on macOS.", version: BuildVersion.current, - subcommands: [StatusCommand.self], + subcommands: [ + StatusCommand.self, + EnableCommand.self, + DisableCommand.self, + ToggleCommand.self, + ], defaultSubcommand: StatusCommand.self ) } + +enum CommandComposition { + static func clamshellService() -> ClamshellService { + let runner = FoundationProcessRunner() + return ClamshellService( + stateReader: PowerSettingsClient(runner: runner), + stateWriter: PrivilegedHelperClient(runner: runner) + ) + } +} diff --git a/Sources/ClamshellCLI/Commands/DisableCommand.swift b/Sources/ClamshellCLI/Commands/DisableCommand.swift new file mode 100644 index 0000000..6489648 --- /dev/null +++ b/Sources/ClamshellCLI/Commands/DisableCommand.swift @@ -0,0 +1,15 @@ +import ArgumentParser + +struct DisableCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "disable", + abstract: "Prevent clamshell mode while using battery power." + ) + + @OptionGroup var output: OutputOptions + + func run() throws { + let result = try CommandComposition.clamshellService().set(.disabled) + Console(isQuiet: output.quiet).writeTransition(result) + } +} diff --git a/Sources/ClamshellCLI/Commands/EnableCommand.swift b/Sources/ClamshellCLI/Commands/EnableCommand.swift new file mode 100644 index 0000000..38123ea --- /dev/null +++ b/Sources/ClamshellCLI/Commands/EnableCommand.swift @@ -0,0 +1,15 @@ +import ArgumentParser + +struct EnableCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "enable", + abstract: "Allow clamshell mode while using battery power." + ) + + @OptionGroup var output: OutputOptions + + func run() throws { + let result = try CommandComposition.clamshellService().set(.enabled) + Console(isQuiet: output.quiet).writeTransition(result) + } +} diff --git a/Sources/ClamshellCLI/Commands/StatusCommand.swift b/Sources/ClamshellCLI/Commands/StatusCommand.swift index 75ee953..58839a0 100644 --- a/Sources/ClamshellCLI/Commands/StatusCommand.swift +++ b/Sources/ClamshellCLI/Commands/StatusCommand.swift @@ -7,9 +7,13 @@ struct StatusCommand: ParsableCommand { abstract: "Show the current battery clamshell mode." ) + @OptionGroup var output: OutputOptions + func run() throws { let client = PowerSettingsClient(runner: FoundationProcessRunner()) let state = try client.currentState() - Console().writeLine("Battery clamshell mode: \(state.rawValue)") + Console(isQuiet: output.quiet).writeLine( + "Battery clamshell mode: \(state.rawValue)" + ) } } diff --git a/Sources/ClamshellCLI/Commands/ToggleCommand.swift b/Sources/ClamshellCLI/Commands/ToggleCommand.swift new file mode 100644 index 0000000..ea7dceb --- /dev/null +++ b/Sources/ClamshellCLI/Commands/ToggleCommand.swift @@ -0,0 +1,15 @@ +import ArgumentParser + +struct ToggleCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "toggle", + abstract: "Switch the current battery clamshell mode." + ) + + @OptionGroup var output: OutputOptions + + func run() throws { + let result = try CommandComposition.clamshellService().toggle() + Console(isQuiet: output.quiet).writeTransition(result) + } +} diff --git a/Sources/ClamshellCLI/Console.swift b/Sources/ClamshellCLI/Console.swift index 341d006..ee6ddf1 100644 --- a/Sources/ClamshellCLI/Console.swift +++ b/Sources/ClamshellCLI/Console.swift @@ -1,13 +1,30 @@ +import ArgumentParser +import ClamshellCore import Foundation +struct OutputOptions: ParsableArguments { + @Flag(name: .long, help: "Suppress successful output.") + var quiet = false +} + struct Console { private let standardOutput: FileHandle + private let isQuiet: Bool - init(standardOutput: FileHandle = .standardOutput) { + init(isQuiet: Bool, standardOutput: FileHandle = .standardOutput) { + self.isQuiet = isQuiet self.standardOutput = standardOutput } func writeLine(_ line: String) { + guard !isQuiet else { + return + } standardOutput.write(Data("\(line)\n".utf8)) } + + func writeTransition(_ result: TransitionResult) { + let qualifier = result.didChange ? "" : "already " + writeLine("Battery clamshell mode: \(qualifier)\(result.current.rawValue)") + } } diff --git a/Sources/ClamshellCore/ClamshellError.swift b/Sources/ClamshellCore/ClamshellError.swift index cbc8822..3848dce 100644 --- a/Sources/ClamshellCore/ClamshellError.swift +++ b/Sources/ClamshellCore/ClamshellError.swift @@ -1,6 +1,9 @@ -public enum ClamshellError: Error, Equatable { +import Foundation + +public enum ClamshellError: Error, Equatable, LocalizedError { case invalidHelperArguments case invalidProcessOutput(executable: String, stream: ProcessOutputStream) + case privilegedHelperUnavailable(setupCommand: String) case processFailed( executable: String, terminationStatus: Int32, @@ -8,4 +11,35 @@ public enum ClamshellError: Error, Equatable { ) case stateVerificationFailed(expected: ClamshellState, actual: ClamshellState) case unrecognisedPowerSettings + + public var errorDescription: String? { + switch self { + case .invalidHelperArguments: + "Invalid privileged helper arguments." + case .invalidProcessOutput(let executable, let stream): + "Unable to decode \(stream.rawValue) from \(executable)." + case .privilegedHelperUnavailable(let setupCommand): + "Privileged helper unavailable. Run: \(setupCommand)" + case .processFailed(let executable, let terminationStatus, let standardError): + processFailureDescription( + executable: executable, + terminationStatus: terminationStatus, + standardError: standardError + ) + case .stateVerificationFailed(let expected, let actual): + "Battery clamshell mode verification failed: expected \(expected.rawValue), found \(actual.rawValue)." + case .unrecognisedPowerSettings: + "Unable to read the Battery Power section from pmset." + } + } + + private func processFailureDescription( + executable: String, + terminationStatus: Int32, + standardError: String + ) -> String { + let detail = standardError.trimmingCharacters(in: .whitespacesAndNewlines) + let prefix = "\(executable) exited with status \(terminationStatus)." + return detail.isEmpty ? prefix : "\(prefix) \(detail)" + } } diff --git a/Sources/ClamshellCore/PrivilegedHelperClient.swift b/Sources/ClamshellCore/PrivilegedHelperClient.swift new file mode 100644 index 0000000..6abc595 --- /dev/null +++ b/Sources/ClamshellCore/PrivilegedHelperClient.swift @@ -0,0 +1,26 @@ +public struct PrivilegedHelperClient: PowerStateWriting, Sendable { + public static let setupCommand = #"sudo "$(brew --prefix)/bin/clamshellctl" setup"# + + private static let executable = "/usr/bin/sudo" + private static let helper = "/usr/local/libexec/clamshellctl-helper" + + private let runner: any ProcessRunning + + public init(runner: any ProcessRunning) { + self.runner = runner + } + + public func setState(_ state: ClamshellState) throws { + let action = state == .enabled ? "enable" : "disable" + let result = try runner.run( + Self.executable, + arguments: ["-n", Self.helper, action] + ) + + guard result.terminationStatus == 0 else { + throw ClamshellError.privilegedHelperUnavailable( + setupCommand: Self.setupCommand + ) + } + } +} diff --git a/Tests/ClamshellCoreTests/PrivilegedHelperClientTests.swift b/Tests/ClamshellCoreTests/PrivilegedHelperClientTests.swift new file mode 100644 index 0000000..543409e --- /dev/null +++ b/Tests/ClamshellCoreTests/PrivilegedHelperClientTests.swift @@ -0,0 +1,85 @@ +import Foundation +import Testing + +@testable import ClamshellCore + +@Suite("Privileged helper client") +struct PrivilegedHelperClientTests { + @Test( + "uses non-interactive sudo for one exact helper action", + arguments: [ + (ClamshellState.enabled, "enable"), + (.disabled, "disable"), + ] + ) + func exactInvocation(state: ClamshellState, action: String) throws { + let runner = HelperRecordingRunner( + result: ProcessResult( + standardOutput: "", + standardError: "", + terminationStatus: 0 + ) + ) + + try PrivilegedHelperClient(runner: runner).setState(state) + + #expect( + runner.invocations == [ + HelperInvocation( + executable: "/usr/bin/sudo", + arguments: [ + "-n", + "/usr/local/libexec/clamshellctl-helper", + action, + ] + ) + ]) + } + + @Test("replaces sudo failure details with setup guidance") + func sudoFailure() { + let runner = HelperRecordingRunner( + result: ProcessResult( + standardOutput: "arbitrary output", + standardError: "sensitive arbitrary stderr", + terminationStatus: 1 + ) + ) + + #expect( + throws: ClamshellError.privilegedHelperUnavailable( + setupCommand: #"sudo "$(brew --prefix)/bin/clamshellctl" setup"# + ) + ) { + try PrivilegedHelperClient(runner: runner).setState(.enabled) + } + } +} + +private struct HelperInvocation: Equatable { + let executable: String + let arguments: [String] +} + +private final class HelperRecordingRunner: ProcessRunning, @unchecked Sendable { + private let lock = NSLock() + private let result: ProcessResult + private var recordedInvocations: [HelperInvocation] = [] + + init(result: ProcessResult) { + self.result = result + } + + var invocations: [HelperInvocation] { + lock.withLock { recordedInvocations } + } + + func run(_ executable: String, arguments: [String]) throws -> ProcessResult { + lock.withLock { + recordedInvocations.append( + HelperInvocation(executable: executable, arguments: arguments) + ) + } + return result + } +} From eb23e6ff61e2d84c904bdfdb8c1bd4668847a5b7 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:55:51 +0100 Subject: [PATCH 06/47] fix(security): use protected helper location --- Sources/ClamshellCore/PrivilegedHelperClient.swift | 2 +- .../ClamshellCoreTests/PrivilegedHelperClientTests.swift | 2 +- docs/clamshellctl-design.md | 2 +- docs/implementation-plan.md | 8 ++++---- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/ClamshellCore/PrivilegedHelperClient.swift b/Sources/ClamshellCore/PrivilegedHelperClient.swift index 6abc595..2682a34 100644 --- a/Sources/ClamshellCore/PrivilegedHelperClient.swift +++ b/Sources/ClamshellCore/PrivilegedHelperClient.swift @@ -2,7 +2,7 @@ public struct PrivilegedHelperClient: PowerStateWriting, Sendable { public static let setupCommand = #"sudo "$(brew --prefix)/bin/clamshellctl" setup"# private static let executable = "/usr/bin/sudo" - private static let helper = "/usr/local/libexec/clamshellctl-helper" + private static let helper = "/Library/PrivilegedHelperTools/clamshellctl-helper" private let runner: any ProcessRunning diff --git a/Tests/ClamshellCoreTests/PrivilegedHelperClientTests.swift b/Tests/ClamshellCoreTests/PrivilegedHelperClientTests.swift index 543409e..b643042 100644 --- a/Tests/ClamshellCoreTests/PrivilegedHelperClientTests.swift +++ b/Tests/ClamshellCoreTests/PrivilegedHelperClientTests.swift @@ -29,7 +29,7 @@ struct PrivilegedHelperClientTests { executable: "/usr/bin/sudo", arguments: [ "-n", - "/usr/local/libexec/clamshellctl-helper", + "/Library/PrivilegedHelperTools/clamshellctl-helper", action, ] ) diff --git a/docs/clamshellctl-design.md b/docs/clamshellctl-design.md index 2ed29df..a1f42e0 100644 --- a/docs/clamshellctl-design.md +++ b/docs/clamshellctl-design.md @@ -103,7 +103,7 @@ Homebrew installs the public CLI and an unprivileged helper payload. The compani The documented setup command runs the installed CLI with `sudo`, using its explicit Homebrew path where the administrator's secure path does not include Homebrew. Setup then: -- copies the helper to `/usr/local/libexec/clamshellctl-helper`; +- copies the helper to `/Library/PrivilegedHelperTools/clamshellctl-helper`; - sets ownership to `root:wheel` and mode `0755`; - writes `/etc/sudoers.d/clamshellctl` with mode `0440`; - permits the current user to run only the helper's exact `enable` and `disable` commands without a password; diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index e01c480..e2a1ff8 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -489,8 +489,8 @@ git commit -m "feat(helper): restrict privileged power mutations" Assert that the client runs only: ```text -/usr/bin/sudo -n /usr/local/libexec/clamshellctl-helper enable -/usr/bin/sudo -n /usr/local/libexec/clamshellctl-helper disable +/usr/bin/sudo -n /Library/PrivilegedHelperTools/clamshellctl-helper enable +/usr/bin/sudo -n /Library/PrivilegedHelperTools/clamshellctl-helper disable ``` Map sudo's non-zero result to an error that names the setup command without echoing arbitrary stderr. @@ -531,7 +531,7 @@ git commit -m "feat(cli): add clamshell control commands" - [ ] **Step 1: Test username validation and exact policy text** -Accept only ASCII usernames matching `[A-Za-z0-9._-]+`. The generated policy contains exactly two non-comment command rules, one for `enable` and one for `disable`, both targeting `/usr/local/libexec/clamshellctl-helper`. Reject whitespace, path separators, shell punctuation, empty names, and newlines. +Accept only ASCII usernames matching `[A-Za-z0-9._-]+`. The generated policy contains exactly two non-comment command rules, one for `enable` and one for `disable`, both targeting `/Library/PrivilegedHelperTools/clamshellctl-helper`. Reject whitespace, path separators, shell punctuation, empty names, and newlines. - [ ] **Step 2: Test installation operations through an injected file system** @@ -545,7 +545,7 @@ Resolve the original user from validated `SUDO_USER`; never default to root. Loc Run: `swift test --filter SudoersPolicyTests && swift test --filter PrivilegedInstallationTests` -Expected: all tests use temporary directories and recording runners; `/usr/local/libexec` and `/etc/sudoers.d` remain unchanged. +Expected: all tests use temporary directories and recording runners; `/Library/PrivilegedHelperTools` and `/etc/sudoers.d` remain unchanged. - [ ] **Step 5: Commit installation services** From 78ad84d5a4fc857406c8e495764b0787f804f53c Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:01:35 +0100 Subject: [PATCH 07/47] feat(setup): define secure helper installation --- Sources/ClamshellCore/ClamshellError.swift | 15 + .../PrivilegedHelperClient.swift | 4 +- .../PrivilegedInstallation.swift | 217 ++++++++++++ Sources/ClamshellCore/SudoersPolicy.swift | 33 ++ .../PrivilegedInstallationTests.swift | 332 ++++++++++++++++++ .../SudoersPolicyTests.swift | 49 +++ 6 files changed, 647 insertions(+), 3 deletions(-) create mode 100644 Sources/ClamshellCore/PrivilegedInstallation.swift create mode 100644 Sources/ClamshellCore/SudoersPolicy.swift create mode 100644 Tests/ClamshellCoreTests/PrivilegedInstallationTests.swift create mode 100644 Tests/ClamshellCoreTests/SudoersPolicyTests.swift diff --git a/Sources/ClamshellCore/ClamshellError.swift b/Sources/ClamshellCore/ClamshellError.swift index 3848dce..ab2fd89 100644 --- a/Sources/ClamshellCore/ClamshellError.swift +++ b/Sources/ClamshellCore/ClamshellError.swift @@ -1,8 +1,12 @@ import Foundation public enum ClamshellError: Error, Equatable, LocalizedError { + case administratorPrivilegesRequired(command: String) + case helperPayloadNotFound case invalidHelperArguments case invalidProcessOutput(executable: String, stream: ProcessOutputStream) + case invalidUsername(String) + case originalUserUnavailable case privilegedHelperUnavailable(setupCommand: String) case processFailed( executable: String, @@ -10,14 +14,23 @@ public enum ClamshellError: Error, Equatable, LocalizedError { standardError: String ) case stateVerificationFailed(expected: ClamshellState, actual: ClamshellState) + case sudoersValidationFailed case unrecognisedPowerSettings public var errorDescription: String? { switch self { + case .administratorPrivilegesRequired(let command): + "Administrator privileges are required. Run: \(command)" + case .helperPayloadNotFound: + "Unable to locate the clamshellctl helper payload." case .invalidHelperArguments: "Invalid privileged helper arguments." case .invalidProcessOutput(let executable, let stream): "Unable to decode \(stream.rawValue) from \(executable)." + case .invalidUsername: + "The original account name is unavailable or unsafe." + case .originalUserUnavailable: + "Unable to identify the account that requested setup." case .privilegedHelperUnavailable(let setupCommand): "Privileged helper unavailable. Run: \(setupCommand)" case .processFailed(let executable, let terminationStatus, let standardError): @@ -28,6 +41,8 @@ public enum ClamshellError: Error, Equatable, LocalizedError { ) case .stateVerificationFailed(let expected, let actual): "Battery clamshell mode verification failed: expected \(expected.rawValue), found \(actual.rawValue)." + case .sudoersValidationFailed: + "The generated sudoers policy failed validation." case .unrecognisedPowerSettings: "Unable to read the Battery Power section from pmset." } diff --git a/Sources/ClamshellCore/PrivilegedHelperClient.swift b/Sources/ClamshellCore/PrivilegedHelperClient.swift index 2682a34..32813d3 100644 --- a/Sources/ClamshellCore/PrivilegedHelperClient.swift +++ b/Sources/ClamshellCore/PrivilegedHelperClient.swift @@ -2,8 +2,6 @@ public struct PrivilegedHelperClient: PowerStateWriting, Sendable { public static let setupCommand = #"sudo "$(brew --prefix)/bin/clamshellctl" setup"# private static let executable = "/usr/bin/sudo" - private static let helper = "/Library/PrivilegedHelperTools/clamshellctl-helper" - private let runner: any ProcessRunning public init(runner: any ProcessRunning) { @@ -14,7 +12,7 @@ public struct PrivilegedHelperClient: PowerStateWriting, Sendable { let action = state == .enabled ? "enable" : "disable" let result = try runner.run( Self.executable, - arguments: ["-n", Self.helper, action] + arguments: ["-n", PrivilegedPaths.helper, action] ) guard result.terminationStatus == 0 else { diff --git a/Sources/ClamshellCore/PrivilegedInstallation.swift b/Sources/ClamshellCore/PrivilegedInstallation.swift new file mode 100644 index 0000000..088a67c --- /dev/null +++ b/Sources/ClamshellCore/PrivilegedInstallation.swift @@ -0,0 +1,217 @@ +import Darwin +import Foundation + +public protocol InstallationFileSystem: Sendable { + func itemExists(at path: String) -> Bool + func isRegularFile(at path: String) -> Bool + func copyItem(at source: String, to destination: String) throws + func write(_ contents: String, to path: String) throws + func setOwner(userID: UInt32, groupID: UInt32, at path: String) throws + func setPermissions(_ permissions: UInt16, at path: String) throws + func replaceItem(at destination: String, withItemAt replacement: String) throws + func removeItem(at path: String) throws +} + +public struct FoundationInstallationFileSystem: InstallationFileSystem { + public init() {} + + public func itemExists(at path: String) -> Bool { + var information = stat() + return path.withCString { lstat($0, &information) == 0 } + } + + public func isRegularFile(at path: String) -> Bool { + guard + let attributes = try? FileManager.default.attributesOfItem(atPath: path), + let type = attributes[.type] as? FileAttributeType + else { + return false + } + return type == .typeRegular + } + + public func copyItem(at source: String, to destination: String) throws { + try FileManager.default.copyItem(atPath: source, toPath: destination) + } + + public func write(_ contents: String, to path: String) throws { + try Data(contents.utf8).write(to: URL(fileURLWithPath: path)) + } + + public func setOwner(userID: UInt32, groupID: UInt32, at path: String) throws { + guard chown(path, userID, groupID) == 0 else { + throw currentPOSIXError() + } + } + + public func setPermissions(_ permissions: UInt16, at path: String) throws { + guard chmod(path, mode_t(permissions)) == 0 else { + throw currentPOSIXError() + } + } + + public func replaceItem(at destination: String, withItemAt replacement: String) throws { + guard rename(replacement, destination) == 0 else { + throw currentPOSIXError() + } + } + + public func removeItem(at path: String) throws { + guard itemExists(at: path) else { + return + } + try FileManager.default.removeItem(atPath: path) + } + + private func currentPOSIXError() -> POSIXError { + POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } +} + +public struct InstallationResult: Sendable, Equatable { + public let helperPath: String + public let sudoersPolicyPath: String + + public init(helperPath: String, sudoersPolicyPath: String) { + self.helperPath = helperPath + self.sudoersPolicyPath = sudoersPolicyPath + } +} + +public struct UninstallationResult: Sendable, Equatable { + public let removedPaths: [String] + + public init(removedPaths: [String]) { + self.removedPaths = removedPaths + } +} + +public struct PrivilegedInstallation: Sendable { + private let fileSystem: any InstallationFileSystem + private let runner: any ProcessRunning + private let effectiveUserID: UInt32 + private let environment: [String: String] + private let executablePath: String + private let temporarySuffix: String + + public init( + fileSystem: any InstallationFileSystem, + runner: any ProcessRunning, + effectiveUserID: UInt32, + environment: [String: String], + executablePath: String, + temporarySuffix: String = UUID().uuidString + ) { + self.fileSystem = fileSystem + self.runner = runner + self.effectiveUserID = effectiveUserID + self.environment = environment + self.executablePath = executablePath + self.temporarySuffix = temporarySuffix + } + + public func install() throws -> InstallationResult { + try requireAdministratorPrivileges() + + guard let originalUser = environment["SUDO_USER"], !originalUser.isEmpty else { + throw ClamshellError.originalUserUnavailable + } + let policy = try SudoersPolicy(username: originalUser) + let payload = try helperPayloadPath() + + let helperTemporary = temporaryPath(for: PrivilegedPaths.helper) + var helperTemporaryExists = false + defer { + if helperTemporaryExists { + try? fileSystem.removeItem(at: helperTemporary) + } + } + + try fileSystem.copyItem(at: payload, to: helperTemporary) + helperTemporaryExists = true + try fileSystem.setOwner(userID: 0, groupID: 0, at: helperTemporary) + try fileSystem.setPermissions(0o755, at: helperTemporary) + try fileSystem.replaceItem( + at: PrivilegedPaths.helper, + withItemAt: helperTemporary + ) + helperTemporaryExists = false + + let policyTemporary = temporaryPath(for: PrivilegedPaths.sudoersPolicy) + var policyTemporaryExists = false + defer { + if policyTemporaryExists { + try? fileSystem.removeItem(at: policyTemporary) + } + } + + try fileSystem.write(policy.contents, to: policyTemporary) + policyTemporaryExists = true + try fileSystem.setPermissions(0o440, at: policyTemporary) + + let validation = try runner.run( + "/usr/sbin/visudo", + arguments: ["-cf", policyTemporary] + ) + guard validation.terminationStatus == 0 else { + throw ClamshellError.sudoersValidationFailed + } + + try fileSystem.replaceItem( + at: PrivilegedPaths.sudoersPolicy, + withItemAt: policyTemporary + ) + policyTemporaryExists = false + + return InstallationResult( + helperPath: PrivilegedPaths.helper, + sudoersPolicyPath: PrivilegedPaths.sudoersPolicy + ) + } + + public func uninstall() throws -> UninstallationResult { + try requireAdministratorPrivileges() + + var removedPaths: [String] = [] + for path in [PrivilegedPaths.helper, PrivilegedPaths.sudoersPolicy] + where fileSystem.itemExists(at: path) { + try fileSystem.removeItem(at: path) + removedPaths.append(path) + } + return UninstallationResult(removedPaths: removedPaths) + } + + private func requireAdministratorPrivileges() throws { + guard effectiveUserID == 0 else { + throw ClamshellError.administratorPrivilegesRequired( + command: PrivilegedHelperClient.setupCommand + ) + } + } + + private func helperPayloadPath() throws -> String { + let executable = URL(fileURLWithPath: executablePath).standardizedFileURL + let executableDirectory = executable.deletingLastPathComponent() + var candidates = [ + executableDirectory.appendingPathComponent("clamshellctl-helper").path + ] + + if executableDirectory.lastPathComponent == "bin" { + candidates.append( + executableDirectory + .deletingLastPathComponent() + .appendingPathComponent("libexec/clamshellctl-helper") + .path + ) + } + + guard let payload = candidates.first(where: fileSystem.isRegularFile(at:)) else { + throw ClamshellError.helperPayloadNotFound + } + return payload + } + + private func temporaryPath(for destination: String) -> String { + "\(destination).installing.\(temporarySuffix)" + } +} diff --git a/Sources/ClamshellCore/SudoersPolicy.swift b/Sources/ClamshellCore/SudoersPolicy.swift new file mode 100644 index 0000000..b2d3d69 --- /dev/null +++ b/Sources/ClamshellCore/SudoersPolicy.swift @@ -0,0 +1,33 @@ +public enum PrivilegedPaths { + public static let helper = "/Library/PrivilegedHelperTools/clamshellctl-helper" + public static let sudoersPolicy = "/etc/sudoers.d/clamshellctl" +} + +public struct SudoersPolicy: Sendable, Equatable { + public let username: String + + public init(username: String) throws { + guard username != "root", !username.isEmpty, username.unicodeScalars.allSatisfy(Self.isSafe) + else { + throw ClamshellError.invalidUsername(username) + } + self.username = username + } + + public var contents: String { + """ + \(username) ALL=(root) NOPASSWD: \(PrivilegedPaths.helper) enable + \(username) ALL=(root) NOPASSWD: \(PrivilegedPaths.helper) disable + + """ + } + + private static func isSafe(_ scalar: Unicode.Scalar) -> Bool { + switch scalar.value { + case 45, 46, 48...57, 65...90, 95, 97...122: + true + default: + false + } + } +} diff --git a/Tests/ClamshellCoreTests/PrivilegedInstallationTests.swift b/Tests/ClamshellCoreTests/PrivilegedInstallationTests.swift new file mode 100644 index 0000000..815ffb8 --- /dev/null +++ b/Tests/ClamshellCoreTests/PrivilegedInstallationTests.swift @@ -0,0 +1,332 @@ +import Foundation +import Testing + +@testable import ClamshellCore + +@Suite("Privileged installation") +struct PrivilegedInstallationTests { + @Test("installs the helper and validated policy in order") + func install() throws { + let log = InstallationOperationLog() + let source = "/tmp/build/clamshellctl-helper" + let fileSystem = RecordingInstallationFileSystem( + files: [source: "helper payload"], + log: log + ) + let runner = InstallationRecordingRunner( + result: ProcessResult( + standardOutput: "", + standardError: "", + terminationStatus: 0 + ), + log: log + ) + let installation = PrivilegedInstallation( + fileSystem: fileSystem, + runner: runner, + effectiveUserID: 0, + environment: ["SUDO_USER": "liam"], + executablePath: "/tmp/build/clamshellctl", + temporarySuffix: "test" + ) + + let result = try installation.install() + + let helperTemporary = "\(PrivilegedPaths.helper).installing.test" + let policyTemporary = "\(PrivilegedPaths.sudoersPolicy).installing.test" + #expect( + result + == InstallationResult( + helperPath: PrivilegedPaths.helper, + sudoersPolicyPath: PrivilegedPaths.sudoersPolicy + ) + ) + #expect( + log.operations == [ + .isRegularFile(source), + .copy(source: source, destination: helperTemporary), + .setOwner(path: helperTemporary, userID: 0, groupID: 0), + .setPermissions(path: helperTemporary, permissions: 0o755), + .replace(replacement: helperTemporary, destination: PrivilegedPaths.helper), + .write( + contents: try SudoersPolicy(username: "liam").contents, + path: policyTemporary + ), + .setPermissions(path: policyTemporary, permissions: 0o440), + .run( + executable: "/usr/sbin/visudo", + arguments: ["-cf", policyTemporary] + ), + .replace( + replacement: policyTemporary, + destination: PrivilegedPaths.sudoersPolicy + ), + ] + ) + } + + @Test("finds a Homebrew libexec payload beside the installation prefix") + func homebrewPayload() throws { + let log = InstallationOperationLog() + let source = "/opt/homebrew/Cellar/clamshellctl/0.1.0/libexec/clamshellctl-helper" + let fileSystem = RecordingInstallationFileSystem( + files: [source: "helper payload"], + log: log + ) + let runner = InstallationRecordingRunner(result: .success, log: log) + let installation = PrivilegedInstallation( + fileSystem: fileSystem, + runner: runner, + effectiveUserID: 0, + environment: ["SUDO_USER": "liam"], + executablePath: "/opt/homebrew/Cellar/clamshellctl/0.1.0/bin/clamshellctl", + temporarySuffix: "test" + ) + + _ = try installation.install() + + #expect( + Array(log.operations.prefix(3)) == [ + .isRegularFile( + "/opt/homebrew/Cellar/clamshellctl/0.1.0/bin/clamshellctl-helper" + ), + .isRegularFile(source), + .copy( + source: source, + destination: "\(PrivilegedPaths.helper).installing.test" + ), + ] + ) + } + + @Test("rejects setup before file access when not running as root") + func rootRequired() { + let log = InstallationOperationLog() + let installation = PrivilegedInstallation( + fileSystem: RecordingInstallationFileSystem(files: [:], log: log), + runner: InstallationRecordingRunner(result: .success, log: log), + effectiveUserID: 501, + environment: ["SUDO_USER": "liam"], + executablePath: "/tmp/build/clamshellctl", + temporarySuffix: "test" + ) + + #expect( + throws: ClamshellError.administratorPrivilegesRequired( + command: PrivilegedHelperClient.setupCommand + ) + ) { + try installation.install() + } + #expect(log.operations.isEmpty) + } + + @Test("requires the original sudo user") + func originalUserRequired() { + let log = InstallationOperationLog() + let installation = PrivilegedInstallation( + fileSystem: RecordingInstallationFileSystem(files: [:], log: log), + runner: InstallationRecordingRunner(result: .success, log: log), + effectiveUserID: 0, + environment: [:], + executablePath: "/tmp/build/clamshellctl", + temporarySuffix: "test" + ) + + #expect(throws: ClamshellError.originalUserUnavailable) { + try installation.install() + } + #expect(log.operations.isEmpty) + } + + @Test("keeps the existing policy when visudo rejects the replacement") + func failedValidation() { + let log = InstallationOperationLog() + let source = "/tmp/build/clamshellctl-helper" + let fileSystem = RecordingInstallationFileSystem( + files: [ + source: "helper payload", + PrivilegedPaths.sudoersPolicy: "existing policy", + ], + log: log + ) + let runner = InstallationRecordingRunner( + result: ProcessResult( + standardOutput: "", + standardError: "syntax error", + terminationStatus: 1 + ), + log: log + ) + let installation = PrivilegedInstallation( + fileSystem: fileSystem, + runner: runner, + effectiveUserID: 0, + environment: ["SUDO_USER": "liam"], + executablePath: "/tmp/build/clamshellctl", + temporarySuffix: "test" + ) + + #expect(throws: ClamshellError.sudoersValidationFailed) { + try installation.install() + } + #expect(fileSystem.contents(at: PrivilegedPaths.sudoersPolicy) == "existing policy") + #expect( + !log.operations.contains( + .replace( + replacement: "\(PrivilegedPaths.sudoersPolicy).installing.test", + destination: PrivilegedPaths.sudoersPolicy + ) + ) + ) + } + + @Test("removes only managed paths and is safe to repeat") + func uninstall() throws { + let log = InstallationOperationLog() + let unrelated = "/usr/local/bin/clamshellctl" + let fileSystem = RecordingInstallationFileSystem( + files: [ + PrivilegedPaths.helper: "helper", + PrivilegedPaths.sudoersPolicy: "policy", + unrelated: "unrelated", + ], + log: log + ) + let installation = PrivilegedInstallation( + fileSystem: fileSystem, + runner: InstallationRecordingRunner(result: .success, log: log), + effectiveUserID: 0, + environment: [:], + executablePath: "/tmp/build/clamshellctl", + temporarySuffix: "test" + ) + + let first = try installation.uninstall() + let second = try installation.uninstall() + + #expect(first.removedPaths == [PrivilegedPaths.helper, PrivilegedPaths.sudoersPolicy]) + #expect(second.removedPaths.isEmpty) + #expect(fileSystem.contents(at: unrelated) == "unrelated") + } +} + +private enum InstallationOperation: Equatable { + case itemExists(String) + case isRegularFile(String) + case copy(source: String, destination: String) + case setOwner(path: String, userID: UInt32, groupID: UInt32) + case setPermissions(path: String, permissions: UInt16) + case replace(replacement: String, destination: String) + case write(contents: String, path: String) + case remove(String) + case run(executable: String, arguments: [String]) +} + +private final class InstallationOperationLog: @unchecked Sendable { + private let lock = NSLock() + private var recordedOperations: [InstallationOperation] = [] + + var operations: [InstallationOperation] { + lock.withLock { recordedOperations } + } + + func append(_ operation: InstallationOperation) { + lock.withLock { + recordedOperations.append(operation) + } + } +} + +private final class RecordingInstallationFileSystem: + InstallationFileSystem, + @unchecked Sendable +{ + private let lock = NSLock() + private let log: InstallationOperationLog + private var files: [String: String] + + init(files: [String: String], log: InstallationOperationLog) { + self.files = files + self.log = log + } + + func itemExists(at path: String) -> Bool { + log.append(.itemExists(path)) + return lock.withLock { files[path] != nil } + } + + func isRegularFile(at path: String) -> Bool { + log.append(.isRegularFile(path)) + return lock.withLock { files[path] != nil } + } + + func copyItem(at source: String, to destination: String) throws { + log.append(.copy(source: source, destination: destination)) + try lock.withLock { + guard let contents = files[source] else { + throw ClamshellError.helperPayloadNotFound + } + files[destination] = contents + } + } + + func write(_ contents: String, to path: String) throws { + log.append(.write(contents: contents, path: path)) + lock.withLock { + files[path] = contents + } + } + + func setOwner(userID: UInt32, groupID: UInt32, at path: String) throws { + log.append(.setOwner(path: path, userID: userID, groupID: groupID)) + } + + func setPermissions(_ permissions: UInt16, at path: String) throws { + log.append(.setPermissions(path: path, permissions: permissions)) + } + + func replaceItem(at destination: String, withItemAt replacement: String) throws { + log.append(.replace(replacement: replacement, destination: destination)) + try lock.withLock { + guard let contents = files.removeValue(forKey: replacement) else { + throw ClamshellError.helperPayloadNotFound + } + files[destination] = contents + } + } + + func removeItem(at path: String) throws { + log.append(.remove(path)) + _ = lock.withLock { + files.removeValue(forKey: path) + } + } + + func contents(at path: String) -> String? { + lock.withLock { files[path] } + } +} + +private final class InstallationRecordingRunner: ProcessRunning, @unchecked Sendable { + private let result: ProcessResult + private let log: InstallationOperationLog + + init(result: ProcessResult, log: InstallationOperationLog) { + self.result = result + self.log = log + } + + func run(_ executable: String, arguments: [String]) throws -> ProcessResult { + log.append(.run(executable: executable, arguments: arguments)) + return result + } +} + +extension ProcessResult { + fileprivate static let success = ProcessResult( + standardOutput: "", + standardError: "", + terminationStatus: 0 + ) +} diff --git a/Tests/ClamshellCoreTests/SudoersPolicyTests.swift b/Tests/ClamshellCoreTests/SudoersPolicyTests.swift new file mode 100644 index 0000000..25f0697 --- /dev/null +++ b/Tests/ClamshellCoreTests/SudoersPolicyTests.swift @@ -0,0 +1,49 @@ +import Testing + +@testable import ClamshellCore + +@Suite("Sudoers policy") +struct SudoersPolicyTests { + @Test( + "accepts safe ASCII account names", + arguments: ["liam", "Liam1", "liam.name", "liam_name", "liam-name"] + ) + func acceptedUsername(username: String) throws { + #expect(try SudoersPolicy(username: username).username == username) + } + + @Test("generates only the two exact helper commands") + func exactPolicy() throws { + let policy = try SudoersPolicy(username: "liam") + + #expect( + policy.contents + == """ + liam ALL=(root) NOPASSWD: /Library/PrivilegedHelperTools/clamshellctl-helper enable + liam ALL=(root) NOPASSWD: /Library/PrivilegedHelperTools/clamshellctl-helper disable + + """ + ) + } + + @Test("rejects unsafe or ambiguous account names") + func rejectedUsername() { + let invalidUsernames = [ + "", + "root", + "liam smith", + "../liam", + "liam/name", + "liam:wheel", + "liam;command", + "liam\nroot ALL=(ALL) ALL", + "líam", + ] + + for username in invalidUsernames { + #expect(throws: ClamshellError.invalidUsername(username)) { + try SudoersPolicy(username: username) + } + } + } +} From e241a02bc8e66ba261b0fcbe8c25b1d4dcee9bb7 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:06:57 +0100 Subject: [PATCH 08/47] feat(setup): add helper setup and removal commands --- Package.swift | 4 + Sources/ClamshellCLI/ClamshellCommand.swift | 14 ++ .../ClamshellCLI/Commands/SetupCommand.swift | 23 ++++ .../Commands/UninstallCommand.swift | 25 ++++ Sources/ClamshellCore/ClamshellError.swift | 6 + .../PrivilegedHelperClient.swift | 1 + .../PrivilegedInstallation.swift | 102 ++++++++++++++- .../ClamshellCLITests/SetupCommandTests.swift | 34 +++++ .../PrivilegedInstallationTests.swift | 120 +++++++++++++++++- 9 files changed, 322 insertions(+), 7 deletions(-) create mode 100644 Sources/ClamshellCLI/Commands/SetupCommand.swift create mode 100644 Sources/ClamshellCLI/Commands/UninstallCommand.swift create mode 100644 Tests/ClamshellCLITests/SetupCommandTests.swift diff --git a/Package.swift b/Package.swift index 76e94fd..17b9459 100644 --- a/Package.swift +++ b/Package.swift @@ -29,5 +29,9 @@ let package = Package( dependencies: ["ClamshellCore"] ), .testTarget(name: "ClamshellCoreTests", dependencies: ["ClamshellCore"]), + .testTarget( + name: "ClamshellCLITests", + dependencies: ["ClamshellCLI", "ClamshellCore"] + ), ] ) diff --git a/Sources/ClamshellCLI/ClamshellCommand.swift b/Sources/ClamshellCLI/ClamshellCommand.swift index 026a670..0f910aa 100644 --- a/Sources/ClamshellCLI/ClamshellCommand.swift +++ b/Sources/ClamshellCLI/ClamshellCommand.swift @@ -1,5 +1,6 @@ import ArgumentParser import ClamshellCore +import Foundation @main struct ClamshellCommand: ParsableCommand { @@ -12,6 +13,8 @@ struct ClamshellCommand: ParsableCommand { EnableCommand.self, DisableCommand.self, ToggleCommand.self, + SetupCommand.self, + UninstallCommand.self, ], defaultSubcommand: StatusCommand.self ) @@ -25,4 +28,15 @@ enum CommandComposition { stateWriter: PrivilegedHelperClient(runner: runner) ) } + + static func privilegedInstallation() throws -> PrivilegedInstallation { + guard + let executablePath = Bundle.main.executableURL? + .resolvingSymlinksInPath() + .path + else { + throw ClamshellError.executablePathUnavailable + } + return PrivilegedInstallation(executablePath: executablePath) + } } diff --git a/Sources/ClamshellCLI/Commands/SetupCommand.swift b/Sources/ClamshellCLI/Commands/SetupCommand.swift new file mode 100644 index 0000000..eaa463c --- /dev/null +++ b/Sources/ClamshellCLI/Commands/SetupCommand.swift @@ -0,0 +1,23 @@ +import ArgumentParser + +struct SetupCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "setup", + abstract: "Install the restricted privileged helper." + ) + + @OptionGroup var output: OutputOptions + + func run() throws { + let result = try CommandComposition.privilegedInstallation().install() + let console = Console(isQuiet: output.quiet) + + if result.didChange { + console.writeLine("Privileged setup installed:") + console.writeLine(result.helperPath) + console.writeLine(result.sudoersPolicyPath) + } else { + console.writeLine("Privileged setup: already configured") + } + } +} diff --git a/Sources/ClamshellCLI/Commands/UninstallCommand.swift b/Sources/ClamshellCLI/Commands/UninstallCommand.swift new file mode 100644 index 0000000..d520fa4 --- /dev/null +++ b/Sources/ClamshellCLI/Commands/UninstallCommand.swift @@ -0,0 +1,25 @@ +import ArgumentParser + +struct UninstallCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "uninstall", + abstract: "Remove the restricted privileged helper." + ) + + @OptionGroup var output: OutputOptions + + func run() throws { + let result = try CommandComposition.privilegedInstallation().uninstall() + let console = Console(isQuiet: output.quiet) + + guard !result.removedPaths.isEmpty else { + console.writeLine("Privileged setup: already removed") + return + } + + console.writeLine("Privileged setup removed:") + for path in result.removedPaths { + console.writeLine(path) + } + } +} diff --git a/Sources/ClamshellCore/ClamshellError.swift b/Sources/ClamshellCore/ClamshellError.swift index ab2fd89..01b7349 100644 --- a/Sources/ClamshellCore/ClamshellError.swift +++ b/Sources/ClamshellCore/ClamshellError.swift @@ -2,7 +2,9 @@ import Foundation public enum ClamshellError: Error, Equatable, LocalizedError { case administratorPrivilegesRequired(command: String) + case executablePathUnavailable case helperPayloadNotFound + case installationVerificationFailed case invalidHelperArguments case invalidProcessOutput(executable: String, stream: ProcessOutputStream) case invalidUsername(String) @@ -21,8 +23,12 @@ public enum ClamshellError: Error, Equatable, LocalizedError { switch self { case .administratorPrivilegesRequired(let command): "Administrator privileges are required. Run: \(command)" + case .executablePathUnavailable: + "Unable to resolve the clamshellctl executable path." case .helperPayloadNotFound: "Unable to locate the clamshellctl helper payload." + case .installationVerificationFailed: + "The privileged installation could not be verified." case .invalidHelperArguments: "Invalid privileged helper arguments." case .invalidProcessOutput(let executable, let stream): diff --git a/Sources/ClamshellCore/PrivilegedHelperClient.swift b/Sources/ClamshellCore/PrivilegedHelperClient.swift index 32813d3..76ab387 100644 --- a/Sources/ClamshellCore/PrivilegedHelperClient.swift +++ b/Sources/ClamshellCore/PrivilegedHelperClient.swift @@ -1,5 +1,6 @@ public struct PrivilegedHelperClient: PowerStateWriting, Sendable { public static let setupCommand = #"sudo "$(brew --prefix)/bin/clamshellctl" setup"# + public static let uninstallCommand = #"sudo "$(brew --prefix)/bin/clamshellctl" uninstall"# private static let executable = "/usr/bin/sudo" private let runner: any ProcessRunning diff --git a/Sources/ClamshellCore/PrivilegedInstallation.swift b/Sources/ClamshellCore/PrivilegedInstallation.swift index 088a67c..11a1b88 100644 --- a/Sources/ClamshellCore/PrivilegedInstallation.swift +++ b/Sources/ClamshellCore/PrivilegedInstallation.swift @@ -4,6 +4,9 @@ import Foundation public protocol InstallationFileSystem: Sendable { func itemExists(at path: String) -> Bool func isRegularFile(at path: String) -> Bool + func contentsEqual(at firstPath: String, and secondPath: String) -> Bool + func readText(at path: String) throws -> String + func attributes(at path: String) throws -> InstalledFileAttributes func copyItem(at source: String, to destination: String) throws func write(_ contents: String, to path: String) throws func setOwner(userID: UInt32, groupID: UInt32, at path: String) throws @@ -12,6 +15,18 @@ public protocol InstallationFileSystem: Sendable { func removeItem(at path: String) throws } +public struct InstalledFileAttributes: Sendable, Equatable { + public let userID: UInt32 + public let groupID: UInt32 + public let permissions: UInt16 + + public init(userID: UInt32, groupID: UInt32, permissions: UInt16) { + self.userID = userID + self.groupID = groupID + self.permissions = permissions + } +} + public struct FoundationInstallationFileSystem: InstallationFileSystem { public init() {} @@ -30,6 +45,30 @@ public struct FoundationInstallationFileSystem: InstallationFileSystem { return type == .typeRegular } + public func contentsEqual(at firstPath: String, and secondPath: String) -> Bool { + FileManager.default.contentsEqual(atPath: firstPath, andPath: secondPath) + } + + public func readText(at path: String) throws -> String { + try String(contentsOfFile: path, encoding: .utf8) + } + + public func attributes(at path: String) throws -> InstalledFileAttributes { + let attributes = try FileManager.default.attributesOfItem(atPath: path) + guard + let userID = attributes[.ownerAccountID] as? NSNumber, + let groupID = attributes[.groupOwnerAccountID] as? NSNumber, + let permissions = attributes[.posixPermissions] as? NSNumber + else { + throw ClamshellError.installationVerificationFailed + } + return InstalledFileAttributes( + userID: userID.uint32Value, + groupID: groupID.uint32Value, + permissions: permissions.uint16Value + ) + } + public func copyItem(at source: String, to destination: String) throws { try FileManager.default.copyItem(atPath: source, toPath: destination) } @@ -71,10 +110,16 @@ public struct FoundationInstallationFileSystem: InstallationFileSystem { public struct InstallationResult: Sendable, Equatable { public let helperPath: String public let sudoersPolicyPath: String + public let didChange: Bool - public init(helperPath: String, sudoersPolicyPath: String) { + public init( + helperPath: String, + sudoersPolicyPath: String, + didChange: Bool = true + ) { self.helperPath = helperPath self.sudoersPolicyPath = sudoersPolicyPath + self.didChange = didChange } } @@ -110,6 +155,16 @@ public struct PrivilegedInstallation: Sendable { self.temporarySuffix = temporarySuffix } + public init(executablePath: String) { + self.init( + fileSystem: FoundationInstallationFileSystem(), + runner: FoundationProcessRunner(), + effectiveUserID: geteuid(), + environment: ProcessInfo.processInfo.environment, + executablePath: executablePath + ) + } + public func install() throws -> InstallationResult { try requireAdministratorPrivileges() @@ -119,6 +174,14 @@ public struct PrivilegedInstallation: Sendable { let policy = try SudoersPolicy(username: originalUser) let payload = try helperPayloadPath() + if try isConfigured(payload: payload, policy: policy) { + return InstallationResult( + helperPath: PrivilegedPaths.helper, + sudoersPolicyPath: PrivilegedPaths.sudoersPolicy, + didChange: false + ) + } + let helperTemporary = temporaryPath(for: PrivilegedPaths.helper) var helperTemporaryExists = false defer { @@ -147,6 +210,7 @@ public struct PrivilegedInstallation: Sendable { try fileSystem.write(policy.contents, to: policyTemporary) policyTemporaryExists = true + try fileSystem.setOwner(userID: 0, groupID: 0, at: policyTemporary) try fileSystem.setPermissions(0o440, at: policyTemporary) let validation = try runner.run( @@ -163,14 +227,19 @@ public struct PrivilegedInstallation: Sendable { ) policyTemporaryExists = false + guard try isConfigured(payload: payload, policy: policy) else { + throw ClamshellError.installationVerificationFailed + } + return InstallationResult( helperPath: PrivilegedPaths.helper, - sudoersPolicyPath: PrivilegedPaths.sudoersPolicy + sudoersPolicyPath: PrivilegedPaths.sudoersPolicy, + didChange: true ) } public func uninstall() throws -> UninstallationResult { - try requireAdministratorPrivileges() + try requireAdministratorPrivileges(command: PrivilegedHelperClient.uninstallCommand) var removedPaths: [String] = [] for path in [PrivilegedPaths.helper, PrivilegedPaths.sudoersPolicy] @@ -182,9 +251,13 @@ public struct PrivilegedInstallation: Sendable { } private func requireAdministratorPrivileges() throws { + try requireAdministratorPrivileges(command: PrivilegedHelperClient.setupCommand) + } + + private func requireAdministratorPrivileges(command: String) throws { guard effectiveUserID == 0 else { throw ClamshellError.administratorPrivilegesRequired( - command: PrivilegedHelperClient.setupCommand + command: command ) } } @@ -214,4 +287,25 @@ public struct PrivilegedInstallation: Sendable { private func temporaryPath(for destination: String) -> String { "\(destination).installing.\(temporarySuffix)" } + + private func isConfigured(payload: String, policy: SudoersPolicy) throws -> Bool { + guard + fileSystem.isRegularFile(at: PrivilegedPaths.helper), + fileSystem.contentsEqual(at: payload, and: PrivilegedPaths.helper), + try fileSystem.attributes(at: PrivilegedPaths.helper) + == InstalledFileAttributes(userID: 0, groupID: 0, permissions: 0o755), + fileSystem.isRegularFile(at: PrivilegedPaths.sudoersPolicy), + try fileSystem.readText(at: PrivilegedPaths.sudoersPolicy) == policy.contents, + try fileSystem.attributes(at: PrivilegedPaths.sudoersPolicy) + == InstalledFileAttributes(userID: 0, groupID: 0, permissions: 0o440) + else { + return false + } + + let validation = try runner.run( + "/usr/sbin/visudo", + arguments: ["-cf", PrivilegedPaths.sudoersPolicy] + ) + return validation.terminationStatus == 0 + } } diff --git a/Tests/ClamshellCLITests/SetupCommandTests.swift b/Tests/ClamshellCLITests/SetupCommandTests.swift new file mode 100644 index 0000000..8c7d03d --- /dev/null +++ b/Tests/ClamshellCLITests/SetupCommandTests.swift @@ -0,0 +1,34 @@ +import ArgumentParser +import ClamshellCore +import Testing + +@testable import ClamshellCLI + +@Suite("Setup commands") +struct SetupCommandTests { + @Test("setup requires an explicit administrator invocation") + func setupRequiresRoot() throws { + var command = try ClamshellCommand.parseAsRoot(["setup"]) + + #expect( + throws: ClamshellError.administratorPrivilegesRequired( + command: PrivilegedHelperClient.setupCommand + ) + ) { + try command.run() + } + } + + @Test("uninstall requires an explicit administrator invocation") + func uninstallRequiresRoot() throws { + var command = try ClamshellCommand.parseAsRoot(["uninstall"]) + + #expect( + throws: ClamshellError.administratorPrivilegesRequired( + command: PrivilegedHelperClient.uninstallCommand + ) + ) { + try command.run() + } + } +} diff --git a/Tests/ClamshellCoreTests/PrivilegedInstallationTests.swift b/Tests/ClamshellCoreTests/PrivilegedInstallationTests.swift index 815ffb8..85b500c 100644 --- a/Tests/ClamshellCoreTests/PrivilegedInstallationTests.swift +++ b/Tests/ClamshellCoreTests/PrivilegedInstallationTests.swift @@ -44,6 +44,7 @@ struct PrivilegedInstallationTests { #expect( log.operations == [ .isRegularFile(source), + .isRegularFile(PrivilegedPaths.helper), .copy(source: source, destination: helperTemporary), .setOwner(path: helperTemporary, userID: 0, groupID: 0), .setPermissions(path: helperTemporary, permissions: 0o755), @@ -52,6 +53,7 @@ struct PrivilegedInstallationTests { contents: try SudoersPolicy(username: "liam").contents, path: policyTemporary ), + .setOwner(path: policyTemporary, userID: 0, groupID: 0), .setPermissions(path: policyTemporary, permissions: 0o440), .run( executable: "/usr/sbin/visudo", @@ -61,6 +63,19 @@ struct PrivilegedInstallationTests { replacement: policyTemporary, destination: PrivilegedPaths.sudoersPolicy ), + .isRegularFile(PrivilegedPaths.helper), + .contentsEqual( + firstPath: source, + secondPath: PrivilegedPaths.helper + ), + .attributes(PrivilegedPaths.helper), + .isRegularFile(PrivilegedPaths.sudoersPolicy), + .readText(PrivilegedPaths.sudoersPolicy), + .attributes(PrivilegedPaths.sudoersPolicy), + .run( + executable: "/usr/sbin/visudo", + arguments: ["-cf", PrivilegedPaths.sudoersPolicy] + ), ] ) } @@ -86,11 +101,12 @@ struct PrivilegedInstallationTests { _ = try installation.install() #expect( - Array(log.operations.prefix(3)) == [ + Array(log.operations.prefix(4)) == [ .isRegularFile( "/opt/homebrew/Cellar/clamshellctl/0.1.0/bin/clamshellctl-helper" ), .isRegularFile(source), + .isRegularFile(PrivilegedPaths.helper), .copy( source: source, destination: "\(PrivilegedPaths.helper).installing.test" @@ -99,6 +115,42 @@ struct PrivilegedInstallationTests { ) } + @Test("recognises an already verified installation") + func alreadyInstalled() throws { + let log = InstallationOperationLog() + let source = "/tmp/build/clamshellctl-helper" + let fileSystem = RecordingInstallationFileSystem( + files: [source: "helper payload"], + log: log + ) + let runner = InstallationRecordingRunner(result: .success, log: log) + let installation = PrivilegedInstallation( + fileSystem: fileSystem, + runner: runner, + effectiveUserID: 0, + environment: ["SUDO_USER": "liam"], + executablePath: "/tmp/build/clamshellctl", + temporarySuffix: "test" + ) + + let first = try installation.install() + let operationCount = log.operations.count + let second = try installation.install() + let repeatedOperations = Array(log.operations.dropFirst(operationCount)) + + #expect(first.didChange) + #expect(!second.didChange) + #expect( + !repeatedOperations.contains { operation in + switch operation { + case .copy, .write, .replace: + true + default: + false + } + }) + } + @Test("rejects setup before file access when not running as root") func rootRequired() { let log = InstallationOperationLog() @@ -214,6 +266,9 @@ struct PrivilegedInstallationTests { private enum InstallationOperation: Equatable { case itemExists(String) case isRegularFile(String) + case contentsEqual(firstPath: String, secondPath: String) + case readText(String) + case attributes(String) case copy(source: String, destination: String) case setOwner(path: String, userID: UInt32, groupID: UInt32) case setPermissions(path: String, permissions: UInt16) @@ -245,9 +300,15 @@ private final class RecordingInstallationFileSystem: private let lock = NSLock() private let log: InstallationOperationLog private var files: [String: String] + private var fileAttributes: [String: InstalledFileAttributes] init(files: [String: String], log: InstallationOperationLog) { self.files = files + fileAttributes = Dictionary( + uniqueKeysWithValues: files.keys.map { + ($0, InstalledFileAttributes(userID: 501, groupID: 20, permissions: 0o755)) + } + ) self.log = log } @@ -261,6 +322,31 @@ private final class RecordingInstallationFileSystem: return lock.withLock { files[path] != nil } } + func contentsEqual(at firstPath: String, and secondPath: String) -> Bool { + log.append(.contentsEqual(firstPath: firstPath, secondPath: secondPath)) + return lock.withLock { files[firstPath] == files[secondPath] } + } + + func readText(at path: String) throws -> String { + log.append(.readText(path)) + return try lock.withLock { + guard let contents = files[path] else { + throw ClamshellError.installationVerificationFailed + } + return contents + } + } + + func attributes(at path: String) throws -> InstalledFileAttributes { + log.append(.attributes(path)) + return try lock.withLock { + guard let attributes = fileAttributes[path] else { + throw ClamshellError.installationVerificationFailed + } + return attributes + } + } + func copyItem(at source: String, to destination: String) throws { log.append(.copy(source: source, destination: destination)) try lock.withLock { @@ -268,6 +354,7 @@ private final class RecordingInstallationFileSystem: throw ClamshellError.helperPayloadNotFound } files[destination] = contents + fileAttributes[destination] = fileAttributes[source] } } @@ -275,15 +362,40 @@ private final class RecordingInstallationFileSystem: log.append(.write(contents: contents, path: path)) lock.withLock { files[path] = contents + fileAttributes[path] = InstalledFileAttributes( + userID: 0, + groupID: 0, + permissions: 0o600 + ) } } func setOwner(userID: UInt32, groupID: UInt32, at path: String) throws { log.append(.setOwner(path: path, userID: userID, groupID: groupID)) + try lock.withLock { + guard let attributes = fileAttributes[path] else { + throw ClamshellError.installationVerificationFailed + } + fileAttributes[path] = InstalledFileAttributes( + userID: userID, + groupID: groupID, + permissions: attributes.permissions + ) + } } func setPermissions(_ permissions: UInt16, at path: String) throws { log.append(.setPermissions(path: path, permissions: permissions)) + try lock.withLock { + guard let attributes = fileAttributes[path] else { + throw ClamshellError.installationVerificationFailed + } + fileAttributes[path] = InstalledFileAttributes( + userID: attributes.userID, + groupID: attributes.groupID, + permissions: permissions + ) + } } func replaceItem(at destination: String, withItemAt replacement: String) throws { @@ -293,13 +405,15 @@ private final class RecordingInstallationFileSystem: throw ClamshellError.helperPayloadNotFound } files[destination] = contents + fileAttributes[destination] = fileAttributes.removeValue(forKey: replacement) } } func removeItem(at path: String) throws { log.append(.remove(path)) - _ = lock.withLock { - files.removeValue(forKey: path) + lock.withLock { + _ = files.removeValue(forKey: path) + _ = fileAttributes.removeValue(forKey: path) } } From e0191c37a8448e0ed26f860a96a6ebb6db177e8b Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:44:27 +0100 Subject: [PATCH 09/47] docs(quality): define repository standards --- docs/repository-quality-design.md | 267 ++++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 docs/repository-quality-design.md diff --git a/docs/repository-quality-design.md b/docs/repository-quality-design.md new file mode 100644 index 0000000..7e6caa7 --- /dev/null +++ b/docs/repository-quality-design.md @@ -0,0 +1,267 @@ +# Repository Quality Design + +## Summary + +This document defines the quality standard for `clamshellctl`. It covers Swift +style enforcement, source organisation, continuous integration, repository +governance, security scanning, and the GitGuardian test-fixture finding. + +The goal is an idiomatic and maintainable Swift project whose local checks and +GitHub checks produce the same result. The work must preserve the existing CLI +behaviour and privileged-operation boundaries. + +## Style enforcement + +The project follows the [Google Swift Style Guide][google-swift-style]. Two +tools enforce separate parts of that standard: + +- `swift-format` owns source formatting. Its checked-in `.swift-format` + configuration uses two-space indentation, a 100-column line limit, + deterministic imports, and the applicable Google conventions. +- [SwiftLint][swiftlint] owns maintainability and correctness rules that + formatting cannot express. Its checked-in `.swiftlint.yml` covers naming, + API hygiene, unsafe constructs, complexity, and selected opt-in rules. + +The SwiftLint command plugin is an exact Swift Package Manager dependency. The +project does not attach it as an Xcode build plugin. Local development and CI +therefore use the same version without an Xcode trust prompt. Dependabot keeps +the pinned version current through normal pull requests. + +SwiftLint configuration must be deliberate. The project does not enable every +opt-in rule, duplicate formatting rules owned by `swift-format`, or accept a +baseline of existing violations. A suppression must be limited to the smallest +declaration or line and include a reason when the code does not make the reason +obvious. + +`.editorconfig` defines editor-neutral properties for Swift and repository +files: UTF-8, LF endings, a final newline, trailing-whitespace handling, and +two-space indentation where appropriate. It complements the Swift tools; it is +not the source of truth for Swift syntax formatting. + +## Source organisation + +Directories describe responsibilities rather than current consumers. The core +module uses these domains: + +```text +Sources/ClamshellCore/ +├── Errors/ +├── Power/ +├── Privilege/ +│ └── Installation/ +├── Process/ +├── State/ +└── Timing/ +``` + +- `State` owns the clamshell state and state-transition decisions. +- `Power` owns `pmset` parsing and the read or mutation boundary. +- `Process` owns process invocation values, protocols, and Foundation adapters. +- `Privilege` owns the helper client and the exact sudoers policy. +- `Privilege/Installation` owns privileged installation and removal workflows, + their filesystem boundary, and operation results. +- `Timing` owns duration parsing, timer metadata, and timer lifecycle rules. +- `Errors` owns domain errors and their stable presentation or exit semantics. + +CLI subcommands remain in `Sources/ClamshellCLI/Commands`. Command composition, +console output, and entry-point concerns use focused files outside that folder. +The helper remains a thin executable target. Future app and control-extension +folders continue to follow the architecture in `clamshellctl-design.md`. + +Tests mirror production responsibilities: + +```text +Tests/ClamshellCoreTests/ +├── Power/ +├── Privilege/ +│ └── Installation/ +├── Process/ +├── State/ +├── Support/ +└── Timing/ +``` + +Reusable test infrastructure belongs in `Support`. A fake used by one suite +remains private in that suite's file. + +## File and declaration boundaries + +A production file has one primary responsibility, usually represented by one +primary top-level type. This is a design rule rather than a declaration-count +rule. Closely related declarations may share a file when separating them would +make the code harder to understand. Examples include: + +- A protocol beside its sole production implementation. +- A small result value beside the operation that returns it. +- A private helper used only by the file's primary type. +- A test suite and its suite-specific private fakes. + +Independent public types, reusable protocols, and unrelated operation results +use separate files. Existing mixed-responsibility files are split where they +cross these boundaries. In particular, privileged installation workflow, +filesystem access, Foundation filesystem adaptation, and installation results +must not remain bundled as unrelated declarations in one large file. + +The project does not add a brittle script that counts top-level declarations. +SwiftLint complexity limits and code review enforce the intent while preserving +the useful exceptions in the Google guide. + +## Idiomatic Swift boundaries + +`ClamshellCore` stays independent of argument parsing, SwiftUI, WidgetKit, and +App Intents. Domain decisions use values and narrow protocols. Foundation types +adapt operating-system behaviour at the edge. + +System operations remain injectable. Tests must not invoke real `pmset`, +`sudo`, filesystem ownership changes, or `launchd`. Generic dumping grounds +such as `Utils`, `Common`, or `Models` are prohibited. + +Access control is as narrow as target boundaries permit. Declarations exposed +to the future app package product are public and have concise documentation. +Other declarations remain internal or private. Documentation describes +contracts, constraints, and failure behaviour; implementation comments explain +non-obvious decisions instead of restating code. + +The refactor does not change command output, exit codes, accepted arguments, +privileged paths, or the sudoers allow-list unless a separate behaviour change +is explicitly designed and tested. + +## Error handling + +Errors remain typed and actionable. Domain errors preserve stable CLI exit-code +behaviour. Platform adapters include enough underlying context to diagnose a +failure but do not expose sensitive command output unnecessarily. + +The codebase does not introduce forced casts, forced tries, avoidable forced +unwraps, silent catches, or `fatalError` for recoverable conditions. A process, +filesystem, parsing, or verification failure propagates through an explicit +error path and receives behavioural test coverage. + +## Testing and local verification + +Tests verify externally observable behaviour rather than private implementation +shape. Coverage includes: + +- Valid and malformed battery sections in `pmset` output. +- Idempotent enable and disable transitions. +- Exact privileged command allow-listing. +- Installation ownership, permissions, rollback, and repeatability. +- Process output capture and failure propagation. +- CLI exit codes and human-readable output. +- Regression protection before existing code is moved or rewritten. + +The repository exposes one documented local verification entry point. It runs, +in order: + +1. `swift-format` in strict lint mode. +2. SwiftLint in strict lint mode. +3. The complete Swift test suite. +4. Debug and release package builds. +5. XcodeGen and the companion-app build once those targets exist. + +Each command stops on failure and preserves the failing tool's output. CI uses +the same commands rather than maintaining an independent implementation. + +## GitHub Actions + +All third-party actions are pinned to immutable commit SHAs. Workflows receive +the minimum required permissions. + +### Continuous integration + +`ci.yml` runs on pull requests and pushes to `main` using a macOS runner. It +checks formatting, runs SwiftLint, executes tests, and builds debug and release +configurations. It also generates and builds the Xcode project after the native +targets are introduced. + +### CodeQL + +`codeql.yml` uses [GitHub CodeQL][codeql-compiled] to analyse Swift on pull +requests, pushes to `main`, a weekly schedule, and manual dispatch. It runs on +macOS and uses an explicit manual Swift build so the analysed targets are +deterministic. The workflow enables the `security-extended` query suite and +publishes results through GitHub code scanning. + +### Pull-request titles + +`pr-title.yml` enforces `verb(area): description`. Accepted verbs follow the +repository's release-please conventions. The same structure is used for local +commits. + +### Releases and dependency updates + +`release.yml` uses release-please to create release pull requests and GitHub +releases. Artifact upload remains output-gated until the CLI and DMG packaging +work exists. Dependabot checks Swift Package Manager and GitHub Actions weekly +and groups compatible routine updates. + +## Repository configuration + +The public repository has a clear description and relevant topics. Issues +remain enabled. Unused wiki and project features are disabled. Merge +configuration allows squash merges only and deletes merged branches +automatically. + +Repository metadata includes: + +- `CODEOWNERS`, assigning the repository to `@LMLiam`. +- A pull-request template. +- Structured bug, feature, and security issue forms. +- Contributing, security, and support policies in GitHub's recognised + `.github` locations. +- Consistent type, area, priority, and status labels. +- An MVP milestone for the existing implementation issues. + +Private vulnerability reporting, Dependabot alerts, Dependabot security +updates, secret scanning, and push protection are enabled where GitHub makes +them available to the public repository. + +## Main branch ruleset + +The default-branch ruleset requires: + +- A pull request before merging. +- One approving maintainer and code-owner review. +- Dismissal of stale approvals after relevant changes. +- Resolution of every review conversation. +- Passing CI, CodeQL, pull-request-title, and GitGuardian checks. +- Linear history. + +Force pushes and branch deletion are blocked. Repository administrators retain +a bypass so Liam can deliberately admin-merge his own pull requests. + +The ruleset is enabled only after each required workflow has produced its +stable check name. This prevents a configuration that requires a check GitHub +has never observed and therefore cannot satisfy. + +## GitGuardian finding + +The current GitGuardian detection is a sudoers-policy test fixture containing a +sample username and an allowed helper command. It is neither a password nor a +credential. + +The individual incident is resolved with GitGuardian's `Skip: false positive` +action. The implementation must not ignore the test directory, disable the +detector, add a broad match exclusion, or rewrite Git history. Secret scanning +and future detections remain fully active. + +## Completion criteria + +The quality pass is complete when: + +1. The source and tests follow the approved responsibility-based layout. +2. `swift-format` and SwiftLint pass in strict mode with no unexplained + suppressions. +3. All tests and debug and release builds pass. +4. CI and CodeQL complete successfully on the pull request. +5. The GitGuardian false positive is resolved and its check passes. +6. Repository metadata, security features, labels, merge settings, and the + default-branch ruleset match this design. +7. Existing architecture and implementation documentation matches the final + layout and workflows. +8. A final review finds no unresolved correctness, security, or maintainability + issues. + +[codeql-compiled]: https://docs.github.com/en/code-security/concepts/code-scanning/codeql/codeql-for-compiled-languages +[google-swift-style]: https://google.github.io/swift/ +[swiftlint]: https://github.com/realm/SwiftLint From 97c3ebed6e7704a823a3b5aec896a80ed93952c7 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:52:02 +0100 Subject: [PATCH 10/47] docs(quality): plan repository improvements --- .../repository-quality-implementation-plan.md | 1455 +++++++++++++++++ 1 file changed, 1455 insertions(+) create mode 100644 docs/repository-quality-implementation-plan.md diff --git a/docs/repository-quality-implementation-plan.md b/docs/repository-quality-implementation-plan.md new file mode 100644 index 0000000..b48711a --- /dev/null +++ b/docs/repository-quality-implementation-plan.md @@ -0,0 +1,1455 @@ +# Repository Quality Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> `superpowers:executing-plans` to implement this plan task by task. Liam has +> explicitly prohibited subagents for this work. + +**Goal:** Make `clamshellctl` an idiomatic, consistently enforced Swift project +with a complete public GitHub repository, CodeQL analysis, release automation, +and protected-main governance. + +**Architecture:** Preserve the current SwiftPM target boundaries and observable +CLI behaviour. Organise each target by responsibility, keep operating-system +interactions behind protocols, and give formatting, linting, testing, security, +and repository policy separate enforceable owners. + +**Tech Stack:** Swift 6.3, Swift Package Manager, Swift Testing, +`swift-format`, SwiftLint 0.63.2, GitHub Actions, CodeQL, GitGuardian, +release-please v5, Dependabot, `actionlint`, and GitHub rulesets. + +--- + +## File map + +### Production source + +```text +Sources/ClamshellCore/ +├── BuildVersion.swift +├── Errors/ClamshellError.swift +├── Power/ +│ ├── PowerMutation.swift +│ ├── PowerSettingsClient.swift +│ └── PowerSettingsParser.swift +├── Privilege/ +│ ├── PrivilegedHelperClient.swift +│ ├── PrivilegedPaths.swift +│ ├── SudoersPolicy.swift +│ └── Installation/ +│ ├── FoundationInstallationFileSystem.swift +│ ├── InstallationFileSystem.swift +│ ├── InstallationResult.swift +│ ├── PrivilegedInstallation.swift +│ └── UninstallationResult.swift +├── Process/ +│ ├── FoundationProcessRunner.swift +│ ├── ProcessOutputStream.swift +│ ├── ProcessResult.swift +│ └── ProcessRunning.swift +└── State/ + ├── ClamshellService.swift + ├── ClamshellState.swift + ├── PowerStateReading.swift + ├── PowerStateWriting.swift + └── TransitionResult.swift + +Sources/ClamshellCLI/ +├── ClamshellCommand.swift +├── CommandComposition.swift +├── Console.swift +├── OutputOptions.swift +└── Commands/*.swift +``` + +`BuildVersion.swift` stays at the module root because it is package metadata, +not a domain. No one-file `Versioning` directory is introduced. Issue #4 adds +the `Timing` production and test directories when timed enablement exists; this +plan does not commit empty directories. + +### Tests + +```text +Tests/ClamshellCoreTests/ +├── BuildVersionTests.swift +├── Power/*.swift +├── Privilege/*.swift +├── Privilege/Installation/*.swift +├── Process/FoundationProcessRunnerTests.swift +├── State/ClamshellServiceTests.swift +└── Support/InstallationTestSupport.swift + +Tests/ClamshellCLITests/ +└── Commands/SetupCommandTests.swift +``` + +### Quality and repository files + +```text +.editorconfig +.gitattributes +.swift-format +.swiftlint.yml +.github/CODEOWNERS +.github/CODE_OF_CONDUCT.md +.github/CONTRIBUTING.md +.github/SECURITY.md +.github/SUPPORT.md +.github/dependabot.yml +.github/pull_request_template.md +.github/ISSUE_TEMPLATE/config.yml +.github/workflows/ci.yml +.github/workflows/codeql.yml +.github/workflows/pr.yml +.github/workflows/release.yml +.release-please-manifest.json +CHANGELOG.md +release-please-config.json +scripts/check.sh +scripts/check-conventional-subject.sh +``` + +The untracked `.vscode/` directory is user-owned and must remain untouched. + +## Task 1: Record the behavioural baseline + +**Files:** None. + +- [ ] **Step 1: Confirm the protected worktree scope** + +Run: + +```bash +git status --short --branch +``` + +Expected: branch `feat/mvp`; the only untracked path is `.vscode/`. + +- [ ] **Step 2: Run the current formatter check** + +Run: + +```bash +swift format lint --recursive --strict Sources Tests Package.swift +``` + +Expected: exit 0 before the formatting configuration changes. + +- [ ] **Step 3: Run the current tests and builds** + +Run: + +```bash +swift test +swift build +swift build -c release +``` + +Expected: 30 tests in 10 suites pass; both builds exit 0. + +## Task 2: Organise source and tests by responsibility + +**Files:** + +- Move: existing Swift files under `Sources/ClamshellCore/` +- Move: `Sources/ClamshellCLI/ClamshellCommand.swift` +- Move: `Sources/ClamshellCLI/Console.swift` +- Move: existing Swift test files under `Tests/` + +- [ ] **Step 1: Create the responsibility directories** + +Run: + +```bash +mkdir -p Sources/ClamshellCore/{Errors,Power,Privilege/Installation,Process,State} +mkdir -p Tests/ClamshellCoreTests/{Power,Privilege/Installation,Process,State,Support} +mkdir -p Tests/ClamshellCLITests/Commands +``` + +- [ ] **Step 2: Move each intact production file** + +Run: + +```bash +git mv Sources/ClamshellCore/ClamshellError.swift Sources/ClamshellCore/Errors/ +git mv Sources/ClamshellCore/ClamshellService.swift Sources/ClamshellCore/State/ +git mv Sources/ClamshellCore/ClamshellState.swift Sources/ClamshellCore/State/ +git mv Sources/ClamshellCore/PowerSettingsClient.swift Sources/ClamshellCore/Power/ +git mv Sources/ClamshellCore/PowerSettingsParser.swift Sources/ClamshellCore/Power/ +git mv Sources/ClamshellCore/PrivilegedHelperClient.swift \ + Sources/ClamshellCore/Privilege/ +git mv Sources/ClamshellCore/PrivilegedInstallation.swift \ + Sources/ClamshellCore/Privilege/Installation/ +git mv Sources/ClamshellCore/ProcessRunner.swift Sources/ClamshellCore/Process/ +git mv Sources/ClamshellCore/SudoersPolicy.swift Sources/ClamshellCore/Privilege/ +``` + +SwiftPM discovers target sources recursively, so `Package.swift` does not need +path declarations. + +- [ ] **Step 3: Move each intact test file** + +Run: + +```bash +git mv Tests/ClamshellCoreTests/ClamshellServiceTests.swift Tests/ClamshellCoreTests/State/ +git mv Tests/ClamshellCoreTests/PowerMutationTests.swift Tests/ClamshellCoreTests/Power/ +git mv Tests/ClamshellCoreTests/PowerSettingsClientTests.swift Tests/ClamshellCoreTests/Power/ +git mv Tests/ClamshellCoreTests/PowerSettingsParserTests.swift Tests/ClamshellCoreTests/Power/ +git mv Tests/ClamshellCoreTests/PrivilegedHelperClientTests.swift \ + Tests/ClamshellCoreTests/Privilege/ +git mv Tests/ClamshellCoreTests/PrivilegedInstallationTests.swift \ + Tests/ClamshellCoreTests/Privilege/Installation/ +git mv Tests/ClamshellCoreTests/SudoersPolicyTests.swift Tests/ClamshellCoreTests/Privilege/ +git mv Tests/ClamshellCLITests/SetupCommandTests.swift Tests/ClamshellCLITests/Commands/ +``` + +- [ ] **Step 4: Verify the moves did not change behaviour** + +Run: + +```bash +swift test +swift build -c release +``` + +Expected: all 30 tests pass and the release build exits 0. + +- [ ] **Step 5: Commit the mechanical layout change** + +Run: + +```bash +git add Sources Tests +git commit -m "refactor(layout): organise code by responsibility" +``` + +## Task 3: Split mixed-responsibility production files + +**Files:** + +- Modify: `Sources/ClamshellCore/State/ClamshellService.swift` +- Create: `Sources/ClamshellCore/State/PowerStateReading.swift` +- Create: `Sources/ClamshellCore/State/PowerStateWriting.swift` +- Create: `Sources/ClamshellCore/State/TransitionResult.swift` +- Modify: `Sources/ClamshellCore/Power/PowerSettingsClient.swift` +- Create: `Sources/ClamshellCore/Power/PowerMutation.swift` +- Replace: `Sources/ClamshellCore/Process/ProcessRunner.swift` +- Create: four focused files under `Sources/ClamshellCore/Process/` +- Modify: `Sources/ClamshellCore/Privilege/SudoersPolicy.swift` +- Create: `Sources/ClamshellCore/Privilege/PrivilegedPaths.swift` +- Replace: `Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift` +- Create: four supporting installation files +- Modify: `Sources/ClamshellCLI/ClamshellCommand.swift` +- Create: `Sources/ClamshellCLI/CommandComposition.swift` +- Modify: `Sources/ClamshellCLI/Console.swift` +- Create: `Sources/ClamshellCLI/OutputOptions.swift` + +- [ ] **Step 1: Separate state contracts and results** + +Move the two protocols and result value out of `ClamshellService.swift` without +changing their names or signatures. The resulting files contain: + +```swift +// PowerStateReading.swift +public protocol PowerStateReading: Sendable { + func currentState() throws -> ClamshellState +} + +// PowerStateWriting.swift +public protocol PowerStateWriting: Sendable { + func setState(_ state: ClamshellState) throws +} + +// TransitionResult.swift +public struct TransitionResult: Sendable, Equatable { + public let previous: ClamshellState + public let current: ClamshellState + public let didChange: Bool + + public init(previous: ClamshellState, current: ClamshellState, didChange: Bool) { + self.previous = previous + self.current = current + self.didChange = didChange + } +} +``` + +`ClamshellService.swift` then contains only `ClamshellService`. + +- [ ] **Step 2: Separate power mutation arguments** + +Move `PowerMutation` unchanged from `PowerSettingsClient.swift` into +`PowerMutation.swift`. Keep the client focused on reading and writing `pmset`. + +- [ ] **Step 3: Separate process values, protocol, and adapter** + +Delete `ProcessRunner.swift` after distributing its declarations exactly as +follows: + +```text +ProcessOutputStream.swift ProcessOutputStream +ProcessResult.swift ProcessResult +ProcessRunning.swift ProcessRunning +FoundationProcessRunner.swift FoundationProcessRunner and private ProcessOutputCollector +``` + +The collector remains private beside its sole consumer. Do not change process +launching, concurrent pipe collection, UTF-8 validation, or returned values. + +- [ ] **Step 4: Separate privileged paths from policy generation** + +Move `PrivilegedPaths` unchanged into `PrivilegedPaths.swift`. +`SudoersPolicy.swift` then contains only username validation and exact policy +generation. + +- [ ] **Step 5: Separate installation workflow and filesystem adaptation** + +Distribute the declarations from `PrivilegedInstallation.swift` as follows: + +```text +InstallationFileSystem.swift InstallationFileSystem and InstalledFileAttributes +FoundationInstallationFileSystem.swift FoundationInstallationFileSystem +InstallationResult.swift InstallationResult +UninstallationResult.swift UninstallationResult +PrivilegedInstallation.swift PrivilegedInstallation +``` + +Keep `InstalledFileAttributes` beside the protocol whose method returns it. +Retain every existing path, permission (`0755` and `0440`), ownership check, +temporary-file cleanup, `visudo` validation, and idempotency branch. + +- [ ] **Step 6: Separate CLI composition and output options** + +Move `CommandComposition` unchanged into `CommandComposition.swift` and +`OutputOptions` unchanged into `OutputOptions.swift`. Leave +`ClamshellCommand.swift` and `Console.swift` with one primary responsibility +each. + +- [ ] **Step 7: Move the Foundation process tests to their domain** + +Move `FoundationProcessRunnerTests` from +`Tests/ClamshellCoreTests/Power/PowerSettingsClientTests.swift` into +`Tests/ClamshellCoreTests/Process/FoundationProcessRunnerTests.swift`. Keep its +temporary executable fixture and assertions unchanged. + +- [ ] **Step 8: Extract the large installation fixture** + +Move these test-only declarations from +`Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationTests.swift` +to `Tests/ClamshellCoreTests/Support/InstallationTestSupport.swift`: + +```text +InstallationOperation +InstallationOperationLog +InstallationRecordingRunner +RecordingInstallationFileSystem +``` + +Remove `private` only where cross-file test access requires internal access. +Keep suite-specific constants and test methods in the suite file. + +- [ ] **Step 9: Verify the structural refactor** + +Run: + +```bash +swift test +swift build +swift build -c release +``` + +Expected: all 30 tests pass; both builds exit 0; command output and exit codes +are unchanged. + +- [ ] **Step 10: Commit the focused declarations** + +Run: + +```bash +git add Sources Tests +git commit -m "refactor(core): separate domain responsibilities" +``` + +## Task 4: Enforce Google Swift formatting + +**Files:** + +- Create: `.editorconfig` +- Create: `.gitattributes` +- Modify: `.swift-format` +- Modify: `.gitignore` +- Modify: every checked-in Swift file through deterministic formatting + +- [ ] **Step 1: Replace `.swift-format` with the explicit project policy** + +Use: + +```json +{ + "indentation": { + "spaces": 2 + }, + "lineLength": 100, + "maximumBlankLines": 1, + "multiElementCollectionTrailingCommas": true, + "rules": { + "NeverForceUnwrap": true, + "NeverUseForceTry": true, + "NeverUseImplicitlyUnwrappedOptionals": true, + "OrderedImports": true + }, + "version": 1 +} +``` + +- [ ] **Step 2: Add editor-neutral settings** + +Create `.editorconfig`: + +```ini +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.swift] +indent_style = space +indent_size = 2 +max_line_length = 100 + +[{*.json,*.yml,*.yaml}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false +``` + +Create `.gitattributes`: + +```gitattributes +* text=auto eol=lf +*.icns binary +*.png binary +*.dmg binary +``` + +Add these generated paths to `.gitignore` while preserving existing entries: + +```gitignore +Clamshell.xcodeproj/ +DerivedData/ +``` + +- [ ] **Step 3: Confirm the formatter configuration changes the result** + +Run: + +```bash +swift format lint --recursive --strict Sources Tests Package.swift +``` + +Expected: non-zero with formatting diagnostics caused by the change from four +spaces to two. + +- [ ] **Step 4: Apply formatting only** + +Run: + +```bash +swift format --recursive --in-place Sources Tests Package.swift +``` + +Expected: every Swift file uses two-space indentation and Google-compatible +layout. A second strict lint invocation exits 0. + +- [ ] **Step 5: Commit the deterministic formatting** + +Run: + +```bash +git add .editorconfig .gitattributes .gitignore .swift-format Package.swift Sources Tests +git commit -m "style(swift): adopt Google formatting" +``` + +## Task 5: Add strict SwiftLint and public API documentation + +**Files:** + +- Modify: `Package.swift` +- Modify: `Package.resolved` +- Modify: `.swift-format` +- Create: `.swiftlint.yml` +- Modify: public declarations under `Sources/` + +- [ ] **Step 1: Pin the SwiftLint command plugin** + +Add this exact dependency after `swift-argument-parser` in `Package.swift`: + +```swift +.package( + url: "https://github.com/SimplyDanny/SwiftLintPlugins", + exact: "0.63.2" +), +``` + +Do not attach a build-tool plugin to any target. Resolve the exact dependency: + +```bash +swift package resolve +``` + +- [ ] **Step 2: Add a curated non-formatting SwiftLint policy** + +First extend the `rules` object in `.swift-format` with the documentation rules +that become enforceable in this task: + +```json +"AllPublicDeclarationsHaveDocumentation": true, +"BeginDocumentationCommentWithOneLineSummary": true, +"ValidateDocumentationComments": true +``` + +Create `.swiftlint.yml`: + +```yaml +strict: true +check_for_updates: false +allow_zero_lintable_files: false + +excluded: + - .build + - .swiftpm + - Clamshell.xcodeproj + - DerivedData + +disabled_rules: + - closing_brace + - colon + - comma + - leading_whitespace + - line_length + - opening_brace + - operator_whitespace + - return_arrow_whitespace + - statement_position + - force_try + - trailing_newline + - trailing_semicolon + - trailing_whitespace + - vertical_whitespace + +opt_in_rules: + - array_init + - contains_over_filter_count + - contains_over_filter_is_empty + - discouraged_optional_boolean + - empty_collection_literal + - empty_count + - empty_string + - explicit_init + - fatal_error_message + - first_where + - last_where + - legacy_multiple + - modifier_order + - overridden_super_call + - pattern_matching_keywords + - reduce_into + - redundant_nil_coalescing + - sorted_first_last + - toggle_bool + - unavailable_function + - unused_import + +closure_body_length: + warning: 40 + error: 60 +cyclomatic_complexity: + warning: 10 + error: 15 +file_length: + warning: 400 + error: 500 + ignore_comment_only_lines: true +function_body_length: + warning: 40 + error: 60 +function_parameter_count: + warning: 6 + error: 8 +type_body_length: + warning: 250 + error: 350 + +reporter: xcode +``` + +- [ ] **Step 3: Run SwiftLint to expose concrete violations** + +Run: + +```bash +swift package plugin swiftlint --strict +``` + +Expected: non-zero until every reported correctness, naming, complexity, and +unused-code violation is addressed. Do not create a baseline or disable a rule +for the whole repository. + +- [ ] **Step 4: Document every public contract** + +Add concise `///` documentation to each public type, initialiser, property, and +method. The one-line summaries must communicate these exact contracts: + +```text +ClamshellState Whether battery clamshell mode is enabled. +PowerStateReading/Writing Read or request the system state. +TransitionResult Previous, verified current, and change status. +PowerSettingsParser Parse the Battery Power section from pmset. +PowerSettingsClient Read and mutate battery pmset settings. +ProcessRunning/ProcessResult Execute a process and capture both streams. +FoundationProcessRunner Foundation-backed ProcessRunning adapter. +PrivilegedHelperClient Request one allow-listed helper mutation. +PrivilegedPaths Fixed root-owned installation destinations. +SudoersPolicy Validate a username and generate exact policy. +InstallationFileSystem Filesystem operations required by installation. +FoundationInstallationFileSystem Foundation and POSIX filesystem adapter. +PrivilegedInstallation Install, verify, or remove privileged files. +ClamshellService Apply idempotent, verified state transitions. +BuildVersion Release-please-managed package version. +``` + +Document associated values and failure behaviour where a caller needs that +information. Do not add comments to private implementation details unless the +decision is non-obvious. + +- [ ] **Step 5: Resolve every strict lint finding idiomatically** + +Prefer changing code over suppressing a finding. When a rule is inapplicable, +use a line- or declaration-scoped disable with a reason immediately above it. +Run after each group of fixes: + +```bash +swift format --recursive --in-place Sources Tests Package.swift +swift format lint --recursive --strict Sources Tests Package.swift +swift package plugin swiftlint --strict +swift test +``` + +Expected: both linters exit 0 and all 30 tests pass. + +- [ ] **Step 6: Commit linting and API documentation** + +Run: + +```bash +git add .swift-format .swiftlint.yml Package.swift Package.resolved Sources Tests +git commit -m "build(lint): enforce Swift conventions" +``` + +## Task 6: Add one local verification entry point + +**Files:** + +- Create: `scripts/check.sh` +- Create: `scripts/check-conventional-subject.sh` +- Modify: `.github/CONTRIBUTING.md` later in Task 9 + +- [ ] **Step 1: Add conventional-subject validation** + +Create `scripts/check-conventional-subject.sh`: + +```bash +#!/bin/bash +set -euo pipefail + +readonly subject="${1:-}" +readonly verbs='feat|fix|docs|test|build|ci|refactor|perf|style|chore|revert' +readonly pattern="^(${verbs})\([a-z0-9][a-z0-9-]*\): .+" + +if [[ ! "$subject" =~ $pattern ]]; then + echo "Expected verb(area): description, received: $subject" >&2 + exit 1 +fi +``` + +Make it executable and verify both branches: + +```bash +chmod +x scripts/check-conventional-subject.sh +scripts/check-conventional-subject.sh "feat(cli): add status output" +! scripts/check-conventional-subject.sh "feat: add status output" +``` + +Expected: the scoped subject passes and the unscoped subject fails. + +- [ ] **Step 2: Add the complete local check** + +Create executable `scripts/check.sh`: + +```bash +#!/bin/bash +set -euo pipefail + +readonly repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repository_root" + +swift_paths=(Package.swift Sources Tests) +if [[ -d App ]]; then + swift_paths+=(App) +fi + +swift format lint --recursive --strict "${swift_paths[@]}" +swift package plugin swiftlint --strict +swift test +swift build +swift build -c release + +if [[ -d .github/workflows ]]; then + command -v actionlint >/dev/null || { + echo "actionlint is required: brew install actionlint" >&2 + exit 1 + } + actionlint +fi + +if [[ -f project.yml ]]; then + command -v xcodegen >/dev/null || { + echo "XcodeGen is required: brew install xcodegen" >&2 + exit 1 + } + xcodegen generate + xcodebuild \ + -project Clamshell.xcodeproj \ + -scheme ClamshellApp \ + -configuration Debug \ + CODE_SIGNING_ALLOWED=NO \ + build +fi +``` + +Run: + +```bash +chmod +x scripts/check.sh +brew list actionlint >/dev/null 2>&1 || brew install actionlint +scripts/check.sh +``` + +Expected: both linters, all tests, both builds, and actionlint exit 0. + +- [ ] **Step 3: Commit the reusable checks** + +Run: + +```bash +git add scripts +git commit -m "build(checks): add local verification entry point" +``` + +## Task 7: Add deterministic CI and pull-request policy + +**Files:** + +- Create: `.github/workflows/ci.yml` +- Create: `.github/workflows/pr.yml` + +- [ ] **Step 1: Add CI with stable job names** + +Create `ci.yml` with these jobs and exact names: + +```yaml +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + quality: + name: Quality + runs-on: macos-26 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: swift format lint --recursive --strict Sources Tests Package.swift + - run: swift package plugin swiftlint --strict + + tests: + name: Tests + runs-on: macos-26 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: swift test + + build: + name: Build + runs-on: macos-26 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: swift build + - run: swift build -c release + + workflows: + name: Workflow lint + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0 + with: + version: 1.7.12 +``` + +- [ ] **Step 2: Add PR title and commit validation** + +Create `pr.yml` with two stable jobs, `Title` and `Commits`. Both invoke +`scripts/check-conventional-subject.sh`. Check out the pull request's head SHA +with full history for the commit job so the synthetic merge subject is never +validated. + +```yaml +name: PR + +on: + pull_request: + branches: [main] + types: [opened, edited, reopened, synchronize] + +permissions: + contents: read + +jobs: + title: + name: Title + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: scripts/check-conventional-subject.sh "$PR_TITLE" + + commits: + name: Commits + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha }} + - env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + while IFS= read -r subject; do + scripts/check-conventional-subject.sh "$subject" + done < <(git log --format=%s "$BASE_SHA..HEAD") +``` + +- [ ] **Step 3: Validate and commit the workflows** + +Run: + +```bash +actionlint +scripts/check.sh +git add .github/workflows/ci.yml .github/workflows/pr.yml +git commit -m "ci(checks): enforce repository quality" +``` + +Expected: actionlint and the complete local check exit 0. + +## Task 8: Add CodeQL, Dependabot, and release-please + +**Files:** + +- Create: `.github/workflows/codeql.yml` +- Create: `.github/workflows/release.yml` +- Create: `.github/dependabot.yml` +- Create: `release-please-config.json` +- Create: `.release-please-manifest.json` +- Create: `CHANGELOG.md` + +- [ ] **Step 1: Add Swift CodeQL analysis** + +Create `codeql.yml` using `github/codeql-action` commit +`e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81` (`v4`): + +```yaml +name: CodeQL + +on: + pull_request: + branches: [main] + push: + branches: [main] + schedule: + - cron: "23 4 * * 1" + workflow_dispatch: + +concurrency: + group: codeql-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + security-events: write + +jobs: + analyze: + name: Analyze (swift) + runs-on: macos-26 + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 + with: + build-mode: manual + languages: swift + queries: security-extended + - run: swift build + - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 + with: + category: /language:swift +``` + +- [ ] **Step 2: Add grouped dependency updates** + +Create `.github/dependabot.yml`: + +```yaml +version: 2 +updates: + - package-ecosystem: swift + directory: / + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Europe/London + open-pull-requests-limit: 5 + groups: + swift-development: + patterns: + - "*" + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Europe/London + open-pull-requests-limit: 5 + groups: + actions: + patterns: + - "*" +``` + +- [ ] **Step 3: Configure release-please v5** + +Create `.release-please-manifest.json`: + +```json +{ + ".": "0.0.0" +} +``` + +Create `CHANGELOG.md`: + +```markdown +# Changelog + +Notable changes to `clamshellctl` are recorded here by release-please. +``` + +Create `release-please-config.json`: + +```json +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "simple", + "package-name": "clamshellctl", + "include-v-in-tag": true, + "include-component-in-tag": false, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "extra-files": [ + { + "type": "generic", + "path": "Sources/ClamshellCore/BuildVersion.swift" + } + ], + "changelog-sections": [ + {"type": "feat", "section": "Features"}, + {"type": "fix", "section": "Fixes"}, + {"type": "perf", "section": "Performance"}, + {"type": "docs", "section": "Documentation"}, + {"type": "deps", "section": "Dependencies"}, + {"type": "test", "section": "Tests", "hidden": true}, + {"type": "build", "section": "Build", "hidden": true}, + {"type": "ci", "section": "CI", "hidden": true}, + {"type": "refactor", "section": "Refactoring", "hidden": true}, + {"type": "style", "section": "Style", "hidden": true}, + {"type": "chore", "section": "Chores", "hidden": true} + ] + } + } +} +``` + +Create `release.yml` using +`googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7` +(`v5`): + +```yaml +name: Release + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + release-please: + name: Release Please + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + issues: write + pull-requests: write + steps: + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5 + with: + token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json +``` + +Do not add DMG upload until issue #6 supplies the packaging script. + +- [ ] **Step 4: Validate and commit automation** + +Run: + +```bash +actionlint +python3 -m json.tool release-please-config.json >/dev/null +python3 -m json.tool .release-please-manifest.json >/dev/null +scripts/check.sh +git add .github CHANGELOG.md release-please-config.json .release-please-manifest.json +git commit -m "ci(release): add security and release automation" +``` + +Expected: workflow and JSON validation pass; the local quality gate passes. + +## Task 9: Complete public repository documentation + +**Files:** + +- Create: `.github/CODEOWNERS` +- Create: `.github/CODE_OF_CONDUCT.md` +- Create: `.github/CONTRIBUTING.md` +- Create: `.github/SECURITY.md` +- Create: `.github/SUPPORT.md` +- Create: `.github/pull_request_template.md` +- Create: `.github/ISSUE_TEMPLATE/config.yml` +- Modify: `.github/ISSUE_TEMPLATE/bug.yml` +- Modify: `.github/ISSUE_TEMPLATE/feature.yml` +- Modify: `README.md` +- Modify: `docs/clamshellctl-design.md` +- Modify: `docs/implementation-plan.md` + +- [ ] **Step 1: Add ownership and contribution policy** + +Use this CODEOWNERS rule: + +```text +* @LMLiam +``` + +`CONTRIBUTING.md` must contain exact sections for prerequisites, setup, +`scripts/check.sh`, Google Swift style, the one-primary-responsibility file +rule, `verb(area): description`, pull requests, behavioural testing, and +security reporting. It must state that SwiftLint suppressions require a local +reason and that contributors must not run privileged tests against live system +paths. + +Use Contributor Covenant 2.1 unchanged in `CODE_OF_CONDUCT.md`, with +`https://github.com/LMLiam` as the enforcement contact rather than a personal +email address. + +- [ ] **Step 2: Add security and support policy** + +`SECURITY.md` must define supported versions as the latest release, direct +vulnerabilities to GitHub private vulnerability reporting, prohibit public +proofs containing secrets or destructive commands, promise acknowledgement +within seven days, and describe the privileged helper, sudoers policy, and +unsigned distribution as security-sensitive surfaces. + +`SUPPORT.md` must direct bugs to the bug form, feature proposals to the feature +form, security reports to private vulnerability reporting, and macOS or +Homebrew usage questions to a normal GitHub issue without promising individual +support. + +- [ ] **Step 3: Add issue and pull-request guidance** + +Set `blank_issues_enabled: false`. Add contact links for private security +reporting and support. Keep the current bug and feature forms, but make their +required fields and labels consistent with `type: bug` and `type: feature`. + +The pull-request template must include summary, linked issues, behaviour and +security impact, verification commands, screenshots only when UI changes, and +a checklist covering `scripts/check.sh`, documentation, and scoped commit +subjects. + +- [ ] **Step 4: Align public and internal documentation** + +Add CI and CodeQL badges and contributor links to `README.md`. Update the source +trees in `clamshellctl-design.md` and `implementation-plan.md` to match the +responsibility folders. Replace direct formatter-only verification commands +with `scripts/check.sh` where the full quality gate is intended. Preserve issue +#10's rule that the `LMLiam/LMLiam` profile README changes only after the first +public release succeeds. + +- [ ] **Step 5: Verify and commit documentation** + +Run: + +```bash +scripts/check.sh +git diff --check +git add .github README.md docs +git commit -m "docs(community): complete repository guidance" +``` + +Expected: all checks pass and no generated or `.vscode/` file is staged. + +## Task 10: Run the complete local verification and push the branch + +**Files:** None beyond corrections required by the checks. + +- [ ] **Step 1: Run the quality gate from a clean build state** + +Run: + +```bash +swift package clean +scripts/check.sh +git diff --check +git status --short +``` + +Expected: all checks pass; only `.vscode/` remains untracked. + +- [ ] **Step 2: Inspect every commit subject** + +Run: + +```bash +while IFS= read -r subject; do + scripts/check-conventional-subject.sh "$subject" +done < <(git log --format=%s origin/main..HEAD) +``` + +Expected: each subject exits 0. Do not rewrite the historical GitGuardian +fixture commit. + +- [ ] **Step 3: Push the existing feature branch** + +Run: + +```bash +git push origin feat/mvp +``` + +Expected: draft PR #11 updates and CI, PR, CodeQL, GitGuardian, and CodeRabbit +checks appear. Keep the PR in draft while repository configuration is applied. + +## Task 11: Resolve GitGuardian and enable repository features + +**Files:** None. + +- [ ] **Step 1: Resolve only the false-positive incident** + +Open the failing `GitGuardian Security Checks` details for PR #11. Select the +historical occurrence in `Tests/ClamshellCoreTests/SudoersPolicyTests.swift` and +choose `Skip: false positive`. Do not select a path ignore, detector ignore, +test-credential classification, or history rewrite. + +Verify: + +```bash +gh pr checks 11 --repo LMLiam/clamshellctl +``` + +Expected: `GitGuardian Security Checks` is successful. + +- [ ] **Step 2: Apply repository merge and feature settings** + +Run: + +```bash +gh repo edit LMLiam/clamshellctl \ + --description "Control battery clamshell mode on macOS" \ + --enable-issues=true \ + --enable-projects=false \ + --enable-wiki=false \ + --enable-merge-commit=false \ + --enable-rebase-merge=false \ + --enable-squash-merge=true \ + --delete-branch-on-merge \ + --add-topic battery \ + --add-topic clamshell-mode \ + --add-topic command-line-tool \ + --add-topic control-center \ + --add-topic macos \ + --add-topic swift \ + --add-topic widgetkit +``` + +- [ ] **Step 3: Enable available security features** + +Run: + +```bash +gh api --method PUT repos/LMLiam/clamshellctl/vulnerability-alerts +gh api --method PUT repos/LMLiam/clamshellctl/automated-security-fixes +gh api --method PUT repos/LMLiam/clamshellctl/private-vulnerability-reporting +gh api --method PATCH repos/LMLiam/clamshellctl \ + -f 'security_and_analysis[secret_scanning][status]=enabled' \ + -f 'security_and_analysis[secret_scanning_push_protection][status]=enabled' +``` + +Expected: each request succeeds. If GitHub reports that a public-repository +feature is already enabled, verify its state and continue without weakening it. + +- [ ] **Step 4: Curate labels without duplicating existing taxonomy** + +Keep the current `area:*`, `type:*`, `good first issue`, `help wanted`, and +`question` labels. Remove redundant default `bug`, `documentation`, and +`enhancement` labels after confirming no issue uses them. Add: + +```text +priority: high B60205 +priority: medium FBCA04 +priority: low 0E8A16 +status: blocked D93F0B +status: ready 0E8A16 +status: in review 5319E7 +``` + +Keep milestone `v0.1.0` and all current issue assignments. Mark issues #1-#3 +`status: in review`; do not close them before PR #11 merges. Issue #8 remains +open because native-app and DMG release jobs depend on issues #5 and #6. + +## Task 12: Create the protected-main ruleset + +**Files:** None. + +- [ ] **Step 1: Wait for every required check name to exist** + +Run: + +```bash +gh pr checks 11 --repo LMLiam/clamshellctl +``` + +Expected check names: + +```text +Quality +Tests +Build +Workflow lint +Title +Commits +Analyze (swift) +GitGuardian Security Checks +``` + +Do not create the ruleset while any name is absent. + +- [ ] **Step 2: Create the active default-branch ruleset** + +Use `gh api --method POST repos/LMLiam/clamshellctl/rulesets --input -` with a +JSON body that contains: + +```json +{ + "name": "Main", + "target": "branch", + "enforcement": "active", + "bypass_actors": [ + { + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "always" + } + ], + "conditions": { + "ref_name": { + "exclude": [], + "include": ["~DEFAULT_BRANCH"] + } + }, + "rules": [ + {"type": "deletion"}, + {"type": "non_fast_forward"}, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [], + "require_code_owner_review": true, + "require_last_push_approval": false, + "required_review_thread_resolution": true, + "allowed_merge_methods": ["squash"] + } + }, + { + "type": "code_scanning", + "parameters": { + "code_scanning_tools": [ + { + "tool": "CodeQL", + "security_alerts_threshold": "medium_or_higher", + "alerts_threshold": "errors" + } + ] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "do_not_enforce_on_create": false, + "required_status_checks": [ + {"context": "Quality"}, + {"context": "Tests"}, + {"context": "Build"}, + {"context": "Workflow lint"}, + {"context": "Title"}, + {"context": "Commits"}, + {"context": "GitGuardian Security Checks"} + ] + } + } + ] +} +``` + +This requires one maintainer/code-owner approval. Repository-role ID `5` +retains the administrator bypass Liam chose for his own pull requests. + +- [ ] **Step 3: Verify repository state** + +Run: + +```bash +gh api repos/LMLiam/clamshellctl/rulesets +readonly repository_fields="deleteBranchOnMerge,hasProjectsEnabled,hasWikiEnabled" +repository_fields+=",mergeCommitAllowed,rebaseMergeAllowed,squashMergeAllowed" +repository_fields+=",repositoryTopics" +gh repo view LMLiam/clamshellctl \ + --json "$repository_fields" +``` + +Expected: active `Main` ruleset, squash-only merging, automatic branch deletion, +projects and wiki disabled, and all seven topics present. + +## Task 13: Final review and pull-request handoff + +**Files:** Any narrow correction required by review. + +- [ ] **Step 1: Run final verification** + +Run: + +```bash +scripts/check.sh +gh pr checks 11 --repo LMLiam/clamshellctl +git status --short --branch +``` + +Expected: local checks and every required remote check pass; `.vscode/` remains +the only untracked path. + +- [ ] **Step 2: Review the complete branch diff** + +Review: + +```bash +git diff --stat origin/main...HEAD +git diff --check origin/main...HEAD +``` + +Confirm that no user-visible CLI behaviour, privileged command, installation +path, permission, or exit code changed accidentally. Run the configured code +review workflow and resolve only evidence-backed findings. + +- [ ] **Step 3: Update the existing draft PR** + +Keep PR #11's valid title unless the final scope requires a more accurate +`feat(area): description` title. Update its body with: + +```text +- Google Swift style enforced by swift-format and SwiftLint +- Responsibility-based source and test layout +- CI, CodeQL, release-please, and Dependabot +- Community health and repository security configuration +- GitGuardian false positive resolved without weakening scanning +``` + +Keep `Closes #1`, `Closes #2`, and `Closes #3`. Do not add `Closes #8` because +its native-app and DMG acceptance criteria remain outstanding. Leave the pull +request in draft for Liam's final review; he can make it ready or use the +administrator merge bypass after reviewing the complete result. + +- [ ] **Step 4: Report the handoff** + +Provide Liam with the PR URL, commit list, local and remote verification results, +the ruleset URL, and the still-open issue list. State explicitly that the +profile README remains assigned to issue #10 after the first public release. From aba0a1c6db371d3867b55c245302e018861f055f Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:11:30 +0100 Subject: [PATCH 11/47] refactor(layout): organise code by responsibility --- Sources/ClamshellCore/{ => Errors}/ClamshellError.swift | 0 Sources/ClamshellCore/{ => Power}/PowerSettingsClient.swift | 0 Sources/ClamshellCore/{ => Power}/PowerSettingsParser.swift | 0 .../{ => Privilege/Installation}/PrivilegedInstallation.swift | 0 .../ClamshellCore/{ => Privilege}/PrivilegedHelperClient.swift | 0 Sources/ClamshellCore/{ => Privilege}/SudoersPolicy.swift | 0 Sources/ClamshellCore/{ => Process}/ProcessRunner.swift | 0 Sources/ClamshellCore/{ => State}/ClamshellService.swift | 0 Sources/ClamshellCore/{ => State}/ClamshellState.swift | 0 Tests/ClamshellCLITests/{ => Commands}/SetupCommandTests.swift | 0 Tests/ClamshellCoreTests/{ => Power}/PowerMutationTests.swift | 0 .../ClamshellCoreTests/{ => Power}/PowerSettingsClientTests.swift | 0 .../ClamshellCoreTests/{ => Power}/PowerSettingsParserTests.swift | 0 .../Installation}/PrivilegedInstallationTests.swift | 0 .../{ => Privilege}/PrivilegedHelperClientTests.swift | 0 Tests/ClamshellCoreTests/{ => Privilege}/SudoersPolicyTests.swift | 0 Tests/ClamshellCoreTests/{ => State}/ClamshellServiceTests.swift | 0 17 files changed, 0 insertions(+), 0 deletions(-) rename Sources/ClamshellCore/{ => Errors}/ClamshellError.swift (100%) rename Sources/ClamshellCore/{ => Power}/PowerSettingsClient.swift (100%) rename Sources/ClamshellCore/{ => Power}/PowerSettingsParser.swift (100%) rename Sources/ClamshellCore/{ => Privilege/Installation}/PrivilegedInstallation.swift (100%) rename Sources/ClamshellCore/{ => Privilege}/PrivilegedHelperClient.swift (100%) rename Sources/ClamshellCore/{ => Privilege}/SudoersPolicy.swift (100%) rename Sources/ClamshellCore/{ => Process}/ProcessRunner.swift (100%) rename Sources/ClamshellCore/{ => State}/ClamshellService.swift (100%) rename Sources/ClamshellCore/{ => State}/ClamshellState.swift (100%) rename Tests/ClamshellCLITests/{ => Commands}/SetupCommandTests.swift (100%) rename Tests/ClamshellCoreTests/{ => Power}/PowerMutationTests.swift (100%) rename Tests/ClamshellCoreTests/{ => Power}/PowerSettingsClientTests.swift (100%) rename Tests/ClamshellCoreTests/{ => Power}/PowerSettingsParserTests.swift (100%) rename Tests/ClamshellCoreTests/{ => Privilege/Installation}/PrivilegedInstallationTests.swift (100%) rename Tests/ClamshellCoreTests/{ => Privilege}/PrivilegedHelperClientTests.swift (100%) rename Tests/ClamshellCoreTests/{ => Privilege}/SudoersPolicyTests.swift (100%) rename Tests/ClamshellCoreTests/{ => State}/ClamshellServiceTests.swift (100%) diff --git a/Sources/ClamshellCore/ClamshellError.swift b/Sources/ClamshellCore/Errors/ClamshellError.swift similarity index 100% rename from Sources/ClamshellCore/ClamshellError.swift rename to Sources/ClamshellCore/Errors/ClamshellError.swift diff --git a/Sources/ClamshellCore/PowerSettingsClient.swift b/Sources/ClamshellCore/Power/PowerSettingsClient.swift similarity index 100% rename from Sources/ClamshellCore/PowerSettingsClient.swift rename to Sources/ClamshellCore/Power/PowerSettingsClient.swift diff --git a/Sources/ClamshellCore/PowerSettingsParser.swift b/Sources/ClamshellCore/Power/PowerSettingsParser.swift similarity index 100% rename from Sources/ClamshellCore/PowerSettingsParser.swift rename to Sources/ClamshellCore/Power/PowerSettingsParser.swift diff --git a/Sources/ClamshellCore/PrivilegedInstallation.swift b/Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift similarity index 100% rename from Sources/ClamshellCore/PrivilegedInstallation.swift rename to Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift diff --git a/Sources/ClamshellCore/PrivilegedHelperClient.swift b/Sources/ClamshellCore/Privilege/PrivilegedHelperClient.swift similarity index 100% rename from Sources/ClamshellCore/PrivilegedHelperClient.swift rename to Sources/ClamshellCore/Privilege/PrivilegedHelperClient.swift diff --git a/Sources/ClamshellCore/SudoersPolicy.swift b/Sources/ClamshellCore/Privilege/SudoersPolicy.swift similarity index 100% rename from Sources/ClamshellCore/SudoersPolicy.swift rename to Sources/ClamshellCore/Privilege/SudoersPolicy.swift diff --git a/Sources/ClamshellCore/ProcessRunner.swift b/Sources/ClamshellCore/Process/ProcessRunner.swift similarity index 100% rename from Sources/ClamshellCore/ProcessRunner.swift rename to Sources/ClamshellCore/Process/ProcessRunner.swift diff --git a/Sources/ClamshellCore/ClamshellService.swift b/Sources/ClamshellCore/State/ClamshellService.swift similarity index 100% rename from Sources/ClamshellCore/ClamshellService.swift rename to Sources/ClamshellCore/State/ClamshellService.swift diff --git a/Sources/ClamshellCore/ClamshellState.swift b/Sources/ClamshellCore/State/ClamshellState.swift similarity index 100% rename from Sources/ClamshellCore/ClamshellState.swift rename to Sources/ClamshellCore/State/ClamshellState.swift diff --git a/Tests/ClamshellCLITests/SetupCommandTests.swift b/Tests/ClamshellCLITests/Commands/SetupCommandTests.swift similarity index 100% rename from Tests/ClamshellCLITests/SetupCommandTests.swift rename to Tests/ClamshellCLITests/Commands/SetupCommandTests.swift diff --git a/Tests/ClamshellCoreTests/PowerMutationTests.swift b/Tests/ClamshellCoreTests/Power/PowerMutationTests.swift similarity index 100% rename from Tests/ClamshellCoreTests/PowerMutationTests.swift rename to Tests/ClamshellCoreTests/Power/PowerMutationTests.swift diff --git a/Tests/ClamshellCoreTests/PowerSettingsClientTests.swift b/Tests/ClamshellCoreTests/Power/PowerSettingsClientTests.swift similarity index 100% rename from Tests/ClamshellCoreTests/PowerSettingsClientTests.swift rename to Tests/ClamshellCoreTests/Power/PowerSettingsClientTests.swift diff --git a/Tests/ClamshellCoreTests/PowerSettingsParserTests.swift b/Tests/ClamshellCoreTests/Power/PowerSettingsParserTests.swift similarity index 100% rename from Tests/ClamshellCoreTests/PowerSettingsParserTests.swift rename to Tests/ClamshellCoreTests/Power/PowerSettingsParserTests.swift diff --git a/Tests/ClamshellCoreTests/PrivilegedInstallationTests.swift b/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationTests.swift similarity index 100% rename from Tests/ClamshellCoreTests/PrivilegedInstallationTests.swift rename to Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationTests.swift diff --git a/Tests/ClamshellCoreTests/PrivilegedHelperClientTests.swift b/Tests/ClamshellCoreTests/Privilege/PrivilegedHelperClientTests.swift similarity index 100% rename from Tests/ClamshellCoreTests/PrivilegedHelperClientTests.swift rename to Tests/ClamshellCoreTests/Privilege/PrivilegedHelperClientTests.swift diff --git a/Tests/ClamshellCoreTests/SudoersPolicyTests.swift b/Tests/ClamshellCoreTests/Privilege/SudoersPolicyTests.swift similarity index 100% rename from Tests/ClamshellCoreTests/SudoersPolicyTests.swift rename to Tests/ClamshellCoreTests/Privilege/SudoersPolicyTests.swift diff --git a/Tests/ClamshellCoreTests/ClamshellServiceTests.swift b/Tests/ClamshellCoreTests/State/ClamshellServiceTests.swift similarity index 100% rename from Tests/ClamshellCoreTests/ClamshellServiceTests.swift rename to Tests/ClamshellCoreTests/State/ClamshellServiceTests.swift From 5fb2321d2daa97f5ddebe15c7d8a2b0501dde12b Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:15:43 +0100 Subject: [PATCH 12/47] refactor(core): separate domain responsibilities --- Sources/ClamshellCLI/ClamshellCommand.swift | 22 --- Sources/ClamshellCLI/CommandComposition.swift | 23 +++ Sources/ClamshellCLI/Console.swift | 6 - Sources/ClamshellCLI/OutputOptions.swift | 6 + .../ClamshellCore/Power/PowerMutation.swift | 14 ++ .../Power/PowerSettingsClient.swift | 15 -- .../FoundationInstallationFileSystem.swift | 82 ++++++++ .../Installation/InstallationFileSystem.swift | 25 +++ .../Installation/InstallationResult.swift | 15 ++ .../Installation/PrivilegedInstallation.swift | 130 ------------ .../Installation/UninstallationResult.swift | 7 + .../Privilege/PrivilegedPaths.swift | 4 + .../Privilege/SudoersPolicy.swift | 5 - ...er.swift => FoundationProcessRunner.swift} | 25 --- .../Process/ProcessOutputStream.swift | 4 + .../ClamshellCore/Process/ProcessResult.swift | 15 ++ .../Process/ProcessRunning.swift | 3 + .../State/ClamshellService.swift | 20 -- .../State/PowerStateReading.swift | 3 + .../State/PowerStateWriting.swift | 3 + .../State/TransitionResult.swift | 11 ++ .../Power/PowerSettingsClientTests.swift | 20 -- .../PrivilegedInstallationTests.swift | 182 ----------------- .../FoundationProcessRunnerTests.swift | 23 +++ .../Support/InstallationTestSupport.swift | 185 ++++++++++++++++++ 25 files changed, 423 insertions(+), 425 deletions(-) create mode 100644 Sources/ClamshellCLI/CommandComposition.swift create mode 100644 Sources/ClamshellCLI/OutputOptions.swift create mode 100644 Sources/ClamshellCore/Power/PowerMutation.swift create mode 100644 Sources/ClamshellCore/Privilege/Installation/FoundationInstallationFileSystem.swift create mode 100644 Sources/ClamshellCore/Privilege/Installation/InstallationFileSystem.swift create mode 100644 Sources/ClamshellCore/Privilege/Installation/InstallationResult.swift create mode 100644 Sources/ClamshellCore/Privilege/Installation/UninstallationResult.swift create mode 100644 Sources/ClamshellCore/Privilege/PrivilegedPaths.swift rename Sources/ClamshellCore/Process/{ProcessRunner.swift => FoundationProcessRunner.swift} (80%) create mode 100644 Sources/ClamshellCore/Process/ProcessOutputStream.swift create mode 100644 Sources/ClamshellCore/Process/ProcessResult.swift create mode 100644 Sources/ClamshellCore/Process/ProcessRunning.swift create mode 100644 Sources/ClamshellCore/State/PowerStateReading.swift create mode 100644 Sources/ClamshellCore/State/PowerStateWriting.swift create mode 100644 Sources/ClamshellCore/State/TransitionResult.swift create mode 100644 Tests/ClamshellCoreTests/Process/FoundationProcessRunnerTests.swift create mode 100644 Tests/ClamshellCoreTests/Support/InstallationTestSupport.swift diff --git a/Sources/ClamshellCLI/ClamshellCommand.swift b/Sources/ClamshellCLI/ClamshellCommand.swift index 0f910aa..e475874 100644 --- a/Sources/ClamshellCLI/ClamshellCommand.swift +++ b/Sources/ClamshellCLI/ClamshellCommand.swift @@ -1,6 +1,5 @@ import ArgumentParser import ClamshellCore -import Foundation @main struct ClamshellCommand: ParsableCommand { @@ -19,24 +18,3 @@ struct ClamshellCommand: ParsableCommand { defaultSubcommand: StatusCommand.self ) } - -enum CommandComposition { - static func clamshellService() -> ClamshellService { - let runner = FoundationProcessRunner() - return ClamshellService( - stateReader: PowerSettingsClient(runner: runner), - stateWriter: PrivilegedHelperClient(runner: runner) - ) - } - - static func privilegedInstallation() throws -> PrivilegedInstallation { - guard - let executablePath = Bundle.main.executableURL? - .resolvingSymlinksInPath() - .path - else { - throw ClamshellError.executablePathUnavailable - } - return PrivilegedInstallation(executablePath: executablePath) - } -} diff --git a/Sources/ClamshellCLI/CommandComposition.swift b/Sources/ClamshellCLI/CommandComposition.swift new file mode 100644 index 0000000..435b7ff --- /dev/null +++ b/Sources/ClamshellCLI/CommandComposition.swift @@ -0,0 +1,23 @@ +import ClamshellCore +import Foundation + +enum CommandComposition { + static func clamshellService() -> ClamshellService { + let runner = FoundationProcessRunner() + return ClamshellService( + stateReader: PowerSettingsClient(runner: runner), + stateWriter: PrivilegedHelperClient(runner: runner) + ) + } + + static func privilegedInstallation() throws -> PrivilegedInstallation { + guard + let executablePath = Bundle.main.executableURL? + .resolvingSymlinksInPath() + .path + else { + throw ClamshellError.executablePathUnavailable + } + return PrivilegedInstallation(executablePath: executablePath) + } +} diff --git a/Sources/ClamshellCLI/Console.swift b/Sources/ClamshellCLI/Console.swift index ee6ddf1..7023a6e 100644 --- a/Sources/ClamshellCLI/Console.swift +++ b/Sources/ClamshellCLI/Console.swift @@ -1,12 +1,6 @@ -import ArgumentParser import ClamshellCore import Foundation -struct OutputOptions: ParsableArguments { - @Flag(name: .long, help: "Suppress successful output.") - var quiet = false -} - struct Console { private let standardOutput: FileHandle private let isQuiet: Bool diff --git a/Sources/ClamshellCLI/OutputOptions.swift b/Sources/ClamshellCLI/OutputOptions.swift new file mode 100644 index 0000000..f3d88ed --- /dev/null +++ b/Sources/ClamshellCLI/OutputOptions.swift @@ -0,0 +1,6 @@ +import ArgumentParser + +struct OutputOptions: ParsableArguments { + @Flag(name: .long, help: "Suppress successful output.") + var quiet = false +} diff --git a/Sources/ClamshellCore/Power/PowerMutation.swift b/Sources/ClamshellCore/Power/PowerMutation.swift new file mode 100644 index 0000000..ec5770e --- /dev/null +++ b/Sources/ClamshellCore/Power/PowerMutation.swift @@ -0,0 +1,14 @@ +public struct PowerMutation: Sendable, Equatable { + public let state: ClamshellState + + public init(rawArguments: [String]) throws { + switch rawArguments { + case ["enable"]: + state = .enabled + case ["disable"]: + state = .disabled + default: + throw ClamshellError.invalidHelperArguments + } + } +} diff --git a/Sources/ClamshellCore/Power/PowerSettingsClient.swift b/Sources/ClamshellCore/Power/PowerSettingsClient.swift index 94ba45d..fbc4974 100644 --- a/Sources/ClamshellCore/Power/PowerSettingsClient.swift +++ b/Sources/ClamshellCore/Power/PowerSettingsClient.swift @@ -1,18 +1,3 @@ -public struct PowerMutation: Sendable, Equatable { - public let state: ClamshellState - - public init(rawArguments: [String]) throws { - switch rawArguments { - case ["enable"]: - state = .enabled - case ["disable"]: - state = .disabled - default: - throw ClamshellError.invalidHelperArguments - } - } -} - public struct PowerSettingsClient: PowerStateReading, PowerStateWriting, Sendable { private let runner: any ProcessRunning private let parser: PowerSettingsParser diff --git a/Sources/ClamshellCore/Privilege/Installation/FoundationInstallationFileSystem.swift b/Sources/ClamshellCore/Privilege/Installation/FoundationInstallationFileSystem.swift new file mode 100644 index 0000000..6852f0e --- /dev/null +++ b/Sources/ClamshellCore/Privilege/Installation/FoundationInstallationFileSystem.swift @@ -0,0 +1,82 @@ +import Darwin +import Foundation + +public struct FoundationInstallationFileSystem: InstallationFileSystem { + public init() {} + + public func itemExists(at path: String) -> Bool { + var information = stat() + return path.withCString { lstat($0, &information) == 0 } + } + + public func isRegularFile(at path: String) -> Bool { + guard + let attributes = try? FileManager.default.attributesOfItem(atPath: path), + let type = attributes[.type] as? FileAttributeType + else { + return false + } + return type == .typeRegular + } + + public func contentsEqual(at firstPath: String, and secondPath: String) -> Bool { + FileManager.default.contentsEqual(atPath: firstPath, andPath: secondPath) + } + + public func readText(at path: String) throws -> String { + try String(contentsOfFile: path, encoding: .utf8) + } + + public func attributes(at path: String) throws -> InstalledFileAttributes { + let attributes = try FileManager.default.attributesOfItem(atPath: path) + guard + let userID = attributes[.ownerAccountID] as? NSNumber, + let groupID = attributes[.groupOwnerAccountID] as? NSNumber, + let permissions = attributes[.posixPermissions] as? NSNumber + else { + throw ClamshellError.installationVerificationFailed + } + return InstalledFileAttributes( + userID: userID.uint32Value, + groupID: groupID.uint32Value, + permissions: permissions.uint16Value + ) + } + + public func copyItem(at source: String, to destination: String) throws { + try FileManager.default.copyItem(atPath: source, toPath: destination) + } + + public func write(_ contents: String, to path: String) throws { + try Data(contents.utf8).write(to: URL(fileURLWithPath: path)) + } + + public func setOwner(userID: UInt32, groupID: UInt32, at path: String) throws { + guard chown(path, userID, groupID) == 0 else { + throw currentPOSIXError() + } + } + + public func setPermissions(_ permissions: UInt16, at path: String) throws { + guard chmod(path, mode_t(permissions)) == 0 else { + throw currentPOSIXError() + } + } + + public func replaceItem(at destination: String, withItemAt replacement: String) throws { + guard rename(replacement, destination) == 0 else { + throw currentPOSIXError() + } + } + + public func removeItem(at path: String) throws { + guard itemExists(at: path) else { + return + } + try FileManager.default.removeItem(atPath: path) + } + + private func currentPOSIXError() -> POSIXError { + POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } +} diff --git a/Sources/ClamshellCore/Privilege/Installation/InstallationFileSystem.swift b/Sources/ClamshellCore/Privilege/Installation/InstallationFileSystem.swift new file mode 100644 index 0000000..b023107 --- /dev/null +++ b/Sources/ClamshellCore/Privilege/Installation/InstallationFileSystem.swift @@ -0,0 +1,25 @@ +public protocol InstallationFileSystem: Sendable { + func itemExists(at path: String) -> Bool + func isRegularFile(at path: String) -> Bool + func contentsEqual(at firstPath: String, and secondPath: String) -> Bool + func readText(at path: String) throws -> String + func attributes(at path: String) throws -> InstalledFileAttributes + func copyItem(at source: String, to destination: String) throws + func write(_ contents: String, to path: String) throws + func setOwner(userID: UInt32, groupID: UInt32, at path: String) throws + func setPermissions(_ permissions: UInt16, at path: String) throws + func replaceItem(at destination: String, withItemAt replacement: String) throws + func removeItem(at path: String) throws +} + +public struct InstalledFileAttributes: Sendable, Equatable { + public let userID: UInt32 + public let groupID: UInt32 + public let permissions: UInt16 + + public init(userID: UInt32, groupID: UInt32, permissions: UInt16) { + self.userID = userID + self.groupID = groupID + self.permissions = permissions + } +} diff --git a/Sources/ClamshellCore/Privilege/Installation/InstallationResult.swift b/Sources/ClamshellCore/Privilege/Installation/InstallationResult.swift new file mode 100644 index 0000000..4e728ce --- /dev/null +++ b/Sources/ClamshellCore/Privilege/Installation/InstallationResult.swift @@ -0,0 +1,15 @@ +public struct InstallationResult: Sendable, Equatable { + public let helperPath: String + public let sudoersPolicyPath: String + public let didChange: Bool + + public init( + helperPath: String, + sudoersPolicyPath: String, + didChange: Bool = true + ) { + self.helperPath = helperPath + self.sudoersPolicyPath = sudoersPolicyPath + self.didChange = didChange + } +} diff --git a/Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift b/Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift index 11a1b88..e4cc0b6 100644 --- a/Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift +++ b/Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift @@ -1,136 +1,6 @@ import Darwin import Foundation -public protocol InstallationFileSystem: Sendable { - func itemExists(at path: String) -> Bool - func isRegularFile(at path: String) -> Bool - func contentsEqual(at firstPath: String, and secondPath: String) -> Bool - func readText(at path: String) throws -> String - func attributes(at path: String) throws -> InstalledFileAttributes - func copyItem(at source: String, to destination: String) throws - func write(_ contents: String, to path: String) throws - func setOwner(userID: UInt32, groupID: UInt32, at path: String) throws - func setPermissions(_ permissions: UInt16, at path: String) throws - func replaceItem(at destination: String, withItemAt replacement: String) throws - func removeItem(at path: String) throws -} - -public struct InstalledFileAttributes: Sendable, Equatable { - public let userID: UInt32 - public let groupID: UInt32 - public let permissions: UInt16 - - public init(userID: UInt32, groupID: UInt32, permissions: UInt16) { - self.userID = userID - self.groupID = groupID - self.permissions = permissions - } -} - -public struct FoundationInstallationFileSystem: InstallationFileSystem { - public init() {} - - public func itemExists(at path: String) -> Bool { - var information = stat() - return path.withCString { lstat($0, &information) == 0 } - } - - public func isRegularFile(at path: String) -> Bool { - guard - let attributes = try? FileManager.default.attributesOfItem(atPath: path), - let type = attributes[.type] as? FileAttributeType - else { - return false - } - return type == .typeRegular - } - - public func contentsEqual(at firstPath: String, and secondPath: String) -> Bool { - FileManager.default.contentsEqual(atPath: firstPath, andPath: secondPath) - } - - public func readText(at path: String) throws -> String { - try String(contentsOfFile: path, encoding: .utf8) - } - - public func attributes(at path: String) throws -> InstalledFileAttributes { - let attributes = try FileManager.default.attributesOfItem(atPath: path) - guard - let userID = attributes[.ownerAccountID] as? NSNumber, - let groupID = attributes[.groupOwnerAccountID] as? NSNumber, - let permissions = attributes[.posixPermissions] as? NSNumber - else { - throw ClamshellError.installationVerificationFailed - } - return InstalledFileAttributes( - userID: userID.uint32Value, - groupID: groupID.uint32Value, - permissions: permissions.uint16Value - ) - } - - public func copyItem(at source: String, to destination: String) throws { - try FileManager.default.copyItem(atPath: source, toPath: destination) - } - - public func write(_ contents: String, to path: String) throws { - try Data(contents.utf8).write(to: URL(fileURLWithPath: path)) - } - - public func setOwner(userID: UInt32, groupID: UInt32, at path: String) throws { - guard chown(path, userID, groupID) == 0 else { - throw currentPOSIXError() - } - } - - public func setPermissions(_ permissions: UInt16, at path: String) throws { - guard chmod(path, mode_t(permissions)) == 0 else { - throw currentPOSIXError() - } - } - - public func replaceItem(at destination: String, withItemAt replacement: String) throws { - guard rename(replacement, destination) == 0 else { - throw currentPOSIXError() - } - } - - public func removeItem(at path: String) throws { - guard itemExists(at: path) else { - return - } - try FileManager.default.removeItem(atPath: path) - } - - private func currentPOSIXError() -> POSIXError { - POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) - } -} - -public struct InstallationResult: Sendable, Equatable { - public let helperPath: String - public let sudoersPolicyPath: String - public let didChange: Bool - - public init( - helperPath: String, - sudoersPolicyPath: String, - didChange: Bool = true - ) { - self.helperPath = helperPath - self.sudoersPolicyPath = sudoersPolicyPath - self.didChange = didChange - } -} - -public struct UninstallationResult: Sendable, Equatable { - public let removedPaths: [String] - - public init(removedPaths: [String]) { - self.removedPaths = removedPaths - } -} - public struct PrivilegedInstallation: Sendable { private let fileSystem: any InstallationFileSystem private let runner: any ProcessRunning diff --git a/Sources/ClamshellCore/Privilege/Installation/UninstallationResult.swift b/Sources/ClamshellCore/Privilege/Installation/UninstallationResult.swift new file mode 100644 index 0000000..c0d6836 --- /dev/null +++ b/Sources/ClamshellCore/Privilege/Installation/UninstallationResult.swift @@ -0,0 +1,7 @@ +public struct UninstallationResult: Sendable, Equatable { + public let removedPaths: [String] + + public init(removedPaths: [String]) { + self.removedPaths = removedPaths + } +} diff --git a/Sources/ClamshellCore/Privilege/PrivilegedPaths.swift b/Sources/ClamshellCore/Privilege/PrivilegedPaths.swift new file mode 100644 index 0000000..3ccb630 --- /dev/null +++ b/Sources/ClamshellCore/Privilege/PrivilegedPaths.swift @@ -0,0 +1,4 @@ +public enum PrivilegedPaths { + public static let helper = "/Library/PrivilegedHelperTools/clamshellctl-helper" + public static let sudoersPolicy = "/etc/sudoers.d/clamshellctl" +} diff --git a/Sources/ClamshellCore/Privilege/SudoersPolicy.swift b/Sources/ClamshellCore/Privilege/SudoersPolicy.swift index b2d3d69..2bd8ce0 100644 --- a/Sources/ClamshellCore/Privilege/SudoersPolicy.swift +++ b/Sources/ClamshellCore/Privilege/SudoersPolicy.swift @@ -1,8 +1,3 @@ -public enum PrivilegedPaths { - public static let helper = "/Library/PrivilegedHelperTools/clamshellctl-helper" - public static let sudoersPolicy = "/etc/sudoers.d/clamshellctl" -} - public struct SudoersPolicy: Sendable, Equatable { public let username: String diff --git a/Sources/ClamshellCore/Process/ProcessRunner.swift b/Sources/ClamshellCore/Process/FoundationProcessRunner.swift similarity index 80% rename from Sources/ClamshellCore/Process/ProcessRunner.swift rename to Sources/ClamshellCore/Process/FoundationProcessRunner.swift index 7653989..2b849e7 100644 --- a/Sources/ClamshellCore/Process/ProcessRunner.swift +++ b/Sources/ClamshellCore/Process/FoundationProcessRunner.swift @@ -1,31 +1,6 @@ import Dispatch import Foundation -public enum ProcessOutputStream: String, Sendable { - case standardOutput - case standardError -} - -public struct ProcessResult: Sendable, Equatable { - public let standardOutput: String - public let standardError: String - public let terminationStatus: Int32 - - public init( - standardOutput: String, - standardError: String, - terminationStatus: Int32 - ) { - self.standardOutput = standardOutput - self.standardError = standardError - self.terminationStatus = terminationStatus - } -} - -public protocol ProcessRunning: Sendable { - func run(_ executable: String, arguments: [String]) throws -> ProcessResult -} - public struct FoundationProcessRunner: ProcessRunning { public init() {} diff --git a/Sources/ClamshellCore/Process/ProcessOutputStream.swift b/Sources/ClamshellCore/Process/ProcessOutputStream.swift new file mode 100644 index 0000000..71890fb --- /dev/null +++ b/Sources/ClamshellCore/Process/ProcessOutputStream.swift @@ -0,0 +1,4 @@ +public enum ProcessOutputStream: String, Sendable { + case standardOutput + case standardError +} diff --git a/Sources/ClamshellCore/Process/ProcessResult.swift b/Sources/ClamshellCore/Process/ProcessResult.swift new file mode 100644 index 0000000..6bb2522 --- /dev/null +++ b/Sources/ClamshellCore/Process/ProcessResult.swift @@ -0,0 +1,15 @@ +public struct ProcessResult: Sendable, Equatable { + public let standardOutput: String + public let standardError: String + public let terminationStatus: Int32 + + public init( + standardOutput: String, + standardError: String, + terminationStatus: Int32 + ) { + self.standardOutput = standardOutput + self.standardError = standardError + self.terminationStatus = terminationStatus + } +} diff --git a/Sources/ClamshellCore/Process/ProcessRunning.swift b/Sources/ClamshellCore/Process/ProcessRunning.swift new file mode 100644 index 0000000..4fd6acc --- /dev/null +++ b/Sources/ClamshellCore/Process/ProcessRunning.swift @@ -0,0 +1,3 @@ +public protocol ProcessRunning: Sendable { + func run(_ executable: String, arguments: [String]) throws -> ProcessResult +} diff --git a/Sources/ClamshellCore/State/ClamshellService.swift b/Sources/ClamshellCore/State/ClamshellService.swift index 75dc817..bc7dbf0 100644 --- a/Sources/ClamshellCore/State/ClamshellService.swift +++ b/Sources/ClamshellCore/State/ClamshellService.swift @@ -1,23 +1,3 @@ -public protocol PowerStateReading: Sendable { - func currentState() throws -> ClamshellState -} - -public protocol PowerStateWriting: Sendable { - func setState(_ state: ClamshellState) throws -} - -public struct TransitionResult: Sendable, Equatable { - public let previous: ClamshellState - public let current: ClamshellState - public let didChange: Bool - - public init(previous: ClamshellState, current: ClamshellState, didChange: Bool) { - self.previous = previous - self.current = current - self.didChange = didChange - } -} - public struct ClamshellService: Sendable { private let stateReader: any PowerStateReading private let stateWriter: any PowerStateWriting diff --git a/Sources/ClamshellCore/State/PowerStateReading.swift b/Sources/ClamshellCore/State/PowerStateReading.swift new file mode 100644 index 0000000..b51d193 --- /dev/null +++ b/Sources/ClamshellCore/State/PowerStateReading.swift @@ -0,0 +1,3 @@ +public protocol PowerStateReading: Sendable { + func currentState() throws -> ClamshellState +} diff --git a/Sources/ClamshellCore/State/PowerStateWriting.swift b/Sources/ClamshellCore/State/PowerStateWriting.swift new file mode 100644 index 0000000..26f8d7a --- /dev/null +++ b/Sources/ClamshellCore/State/PowerStateWriting.swift @@ -0,0 +1,3 @@ +public protocol PowerStateWriting: Sendable { + func setState(_ state: ClamshellState) throws +} diff --git a/Sources/ClamshellCore/State/TransitionResult.swift b/Sources/ClamshellCore/State/TransitionResult.swift new file mode 100644 index 0000000..7a2b34d --- /dev/null +++ b/Sources/ClamshellCore/State/TransitionResult.swift @@ -0,0 +1,11 @@ +public struct TransitionResult: Sendable, Equatable { + public let previous: ClamshellState + public let current: ClamshellState + public let didChange: Bool + + public init(previous: ClamshellState, current: ClamshellState, didChange: Bool) { + self.previous = previous + self.current = current + self.didChange = didChange + } +} diff --git a/Tests/ClamshellCoreTests/Power/PowerSettingsClientTests.swift b/Tests/ClamshellCoreTests/Power/PowerSettingsClientTests.swift index f20d3ac..ff4280b 100644 --- a/Tests/ClamshellCoreTests/Power/PowerSettingsClientTests.swift +++ b/Tests/ClamshellCoreTests/Power/PowerSettingsClientTests.swift @@ -54,26 +54,6 @@ struct PowerSettingsClientTests { } } -@Suite("Foundation process runner") -struct FoundationProcessRunnerTests { - @Test("captures separate output streams and the exit status") - func capturesProcessResult() throws { - let result = try FoundationProcessRunner().run( - "/bin/sh", - arguments: ["-c", "printf output; printf error >&2; exit 7"] - ) - - #expect( - result - == ProcessResult( - standardOutput: "output", - standardError: "error", - terminationStatus: 7 - ) - ) - } -} - private struct ProcessInvocation: Equatable { let executable: String let arguments: [String] diff --git a/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationTests.swift b/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationTests.swift index 85b500c..891ea79 100644 --- a/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationTests.swift +++ b/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationTests.swift @@ -262,185 +262,3 @@ struct PrivilegedInstallationTests { #expect(fileSystem.contents(at: unrelated) == "unrelated") } } - -private enum InstallationOperation: Equatable { - case itemExists(String) - case isRegularFile(String) - case contentsEqual(firstPath: String, secondPath: String) - case readText(String) - case attributes(String) - case copy(source: String, destination: String) - case setOwner(path: String, userID: UInt32, groupID: UInt32) - case setPermissions(path: String, permissions: UInt16) - case replace(replacement: String, destination: String) - case write(contents: String, path: String) - case remove(String) - case run(executable: String, arguments: [String]) -} - -private final class InstallationOperationLog: @unchecked Sendable { - private let lock = NSLock() - private var recordedOperations: [InstallationOperation] = [] - - var operations: [InstallationOperation] { - lock.withLock { recordedOperations } - } - - func append(_ operation: InstallationOperation) { - lock.withLock { - recordedOperations.append(operation) - } - } -} - -private final class RecordingInstallationFileSystem: - InstallationFileSystem, - @unchecked Sendable -{ - private let lock = NSLock() - private let log: InstallationOperationLog - private var files: [String: String] - private var fileAttributes: [String: InstalledFileAttributes] - - init(files: [String: String], log: InstallationOperationLog) { - self.files = files - fileAttributes = Dictionary( - uniqueKeysWithValues: files.keys.map { - ($0, InstalledFileAttributes(userID: 501, groupID: 20, permissions: 0o755)) - } - ) - self.log = log - } - - func itemExists(at path: String) -> Bool { - log.append(.itemExists(path)) - return lock.withLock { files[path] != nil } - } - - func isRegularFile(at path: String) -> Bool { - log.append(.isRegularFile(path)) - return lock.withLock { files[path] != nil } - } - - func contentsEqual(at firstPath: String, and secondPath: String) -> Bool { - log.append(.contentsEqual(firstPath: firstPath, secondPath: secondPath)) - return lock.withLock { files[firstPath] == files[secondPath] } - } - - func readText(at path: String) throws -> String { - log.append(.readText(path)) - return try lock.withLock { - guard let contents = files[path] else { - throw ClamshellError.installationVerificationFailed - } - return contents - } - } - - func attributes(at path: String) throws -> InstalledFileAttributes { - log.append(.attributes(path)) - return try lock.withLock { - guard let attributes = fileAttributes[path] else { - throw ClamshellError.installationVerificationFailed - } - return attributes - } - } - - func copyItem(at source: String, to destination: String) throws { - log.append(.copy(source: source, destination: destination)) - try lock.withLock { - guard let contents = files[source] else { - throw ClamshellError.helperPayloadNotFound - } - files[destination] = contents - fileAttributes[destination] = fileAttributes[source] - } - } - - func write(_ contents: String, to path: String) throws { - log.append(.write(contents: contents, path: path)) - lock.withLock { - files[path] = contents - fileAttributes[path] = InstalledFileAttributes( - userID: 0, - groupID: 0, - permissions: 0o600 - ) - } - } - - func setOwner(userID: UInt32, groupID: UInt32, at path: String) throws { - log.append(.setOwner(path: path, userID: userID, groupID: groupID)) - try lock.withLock { - guard let attributes = fileAttributes[path] else { - throw ClamshellError.installationVerificationFailed - } - fileAttributes[path] = InstalledFileAttributes( - userID: userID, - groupID: groupID, - permissions: attributes.permissions - ) - } - } - - func setPermissions(_ permissions: UInt16, at path: String) throws { - log.append(.setPermissions(path: path, permissions: permissions)) - try lock.withLock { - guard let attributes = fileAttributes[path] else { - throw ClamshellError.installationVerificationFailed - } - fileAttributes[path] = InstalledFileAttributes( - userID: attributes.userID, - groupID: attributes.groupID, - permissions: permissions - ) - } - } - - func replaceItem(at destination: String, withItemAt replacement: String) throws { - log.append(.replace(replacement: replacement, destination: destination)) - try lock.withLock { - guard let contents = files.removeValue(forKey: replacement) else { - throw ClamshellError.helperPayloadNotFound - } - files[destination] = contents - fileAttributes[destination] = fileAttributes.removeValue(forKey: replacement) - } - } - - func removeItem(at path: String) throws { - log.append(.remove(path)) - lock.withLock { - _ = files.removeValue(forKey: path) - _ = fileAttributes.removeValue(forKey: path) - } - } - - func contents(at path: String) -> String? { - lock.withLock { files[path] } - } -} - -private final class InstallationRecordingRunner: ProcessRunning, @unchecked Sendable { - private let result: ProcessResult - private let log: InstallationOperationLog - - init(result: ProcessResult, log: InstallationOperationLog) { - self.result = result - self.log = log - } - - func run(_ executable: String, arguments: [String]) throws -> ProcessResult { - log.append(.run(executable: executable, arguments: arguments)) - return result - } -} - -extension ProcessResult { - fileprivate static let success = ProcessResult( - standardOutput: "", - standardError: "", - terminationStatus: 0 - ) -} diff --git a/Tests/ClamshellCoreTests/Process/FoundationProcessRunnerTests.swift b/Tests/ClamshellCoreTests/Process/FoundationProcessRunnerTests.swift new file mode 100644 index 0000000..93eaa14 --- /dev/null +++ b/Tests/ClamshellCoreTests/Process/FoundationProcessRunnerTests.swift @@ -0,0 +1,23 @@ +import Testing + +@testable import ClamshellCore + +@Suite("Foundation process runner") +struct FoundationProcessRunnerTests { + @Test("captures separate output streams and the exit status") + func capturesProcessResult() throws { + let result = try FoundationProcessRunner().run( + "/bin/sh", + arguments: ["-c", "printf output; printf error >&2; exit 7"] + ) + + #expect( + result + == ProcessResult( + standardOutput: "output", + standardError: "error", + terminationStatus: 7 + ) + ) + } +} diff --git a/Tests/ClamshellCoreTests/Support/InstallationTestSupport.swift b/Tests/ClamshellCoreTests/Support/InstallationTestSupport.swift new file mode 100644 index 0000000..1ae365c --- /dev/null +++ b/Tests/ClamshellCoreTests/Support/InstallationTestSupport.swift @@ -0,0 +1,185 @@ +import Foundation + +@testable import ClamshellCore + +enum InstallationOperation: Equatable { + case itemExists(String) + case isRegularFile(String) + case contentsEqual(firstPath: String, secondPath: String) + case readText(String) + case attributes(String) + case copy(source: String, destination: String) + case setOwner(path: String, userID: UInt32, groupID: UInt32) + case setPermissions(path: String, permissions: UInt16) + case replace(replacement: String, destination: String) + case write(contents: String, path: String) + case remove(String) + case run(executable: String, arguments: [String]) +} + +final class InstallationOperationLog: @unchecked Sendable { + private let lock = NSLock() + private var recordedOperations: [InstallationOperation] = [] + + var operations: [InstallationOperation] { + lock.withLock { recordedOperations } + } + + func append(_ operation: InstallationOperation) { + lock.withLock { + recordedOperations.append(operation) + } + } +} + +final class RecordingInstallationFileSystem: + InstallationFileSystem, + @unchecked Sendable +{ + private let lock = NSLock() + private let log: InstallationOperationLog + private var files: [String: String] + private var fileAttributes: [String: InstalledFileAttributes] + + init(files: [String: String], log: InstallationOperationLog) { + self.files = files + fileAttributes = Dictionary( + uniqueKeysWithValues: files.keys.map { + ($0, InstalledFileAttributes(userID: 501, groupID: 20, permissions: 0o755)) + } + ) + self.log = log + } + + func itemExists(at path: String) -> Bool { + log.append(.itemExists(path)) + return lock.withLock { files[path] != nil } + } + + func isRegularFile(at path: String) -> Bool { + log.append(.isRegularFile(path)) + return lock.withLock { files[path] != nil } + } + + func contentsEqual(at firstPath: String, and secondPath: String) -> Bool { + log.append(.contentsEqual(firstPath: firstPath, secondPath: secondPath)) + return lock.withLock { files[firstPath] == files[secondPath] } + } + + func readText(at path: String) throws -> String { + log.append(.readText(path)) + return try lock.withLock { + guard let contents = files[path] else { + throw ClamshellError.installationVerificationFailed + } + return contents + } + } + + func attributes(at path: String) throws -> InstalledFileAttributes { + log.append(.attributes(path)) + return try lock.withLock { + guard let attributes = fileAttributes[path] else { + throw ClamshellError.installationVerificationFailed + } + return attributes + } + } + + func copyItem(at source: String, to destination: String) throws { + log.append(.copy(source: source, destination: destination)) + try lock.withLock { + guard let contents = files[source] else { + throw ClamshellError.helperPayloadNotFound + } + files[destination] = contents + fileAttributes[destination] = fileAttributes[source] + } + } + + func write(_ contents: String, to path: String) throws { + log.append(.write(contents: contents, path: path)) + lock.withLock { + files[path] = contents + fileAttributes[path] = InstalledFileAttributes( + userID: 0, + groupID: 0, + permissions: 0o600 + ) + } + } + + func setOwner(userID: UInt32, groupID: UInt32, at path: String) throws { + log.append(.setOwner(path: path, userID: userID, groupID: groupID)) + try lock.withLock { + guard let attributes = fileAttributes[path] else { + throw ClamshellError.installationVerificationFailed + } + fileAttributes[path] = InstalledFileAttributes( + userID: userID, + groupID: groupID, + permissions: attributes.permissions + ) + } + } + + func setPermissions(_ permissions: UInt16, at path: String) throws { + log.append(.setPermissions(path: path, permissions: permissions)) + try lock.withLock { + guard let attributes = fileAttributes[path] else { + throw ClamshellError.installationVerificationFailed + } + fileAttributes[path] = InstalledFileAttributes( + userID: attributes.userID, + groupID: attributes.groupID, + permissions: permissions + ) + } + } + + func replaceItem(at destination: String, withItemAt replacement: String) throws { + log.append(.replace(replacement: replacement, destination: destination)) + try lock.withLock { + guard let contents = files.removeValue(forKey: replacement) else { + throw ClamshellError.helperPayloadNotFound + } + files[destination] = contents + fileAttributes[destination] = fileAttributes.removeValue(forKey: replacement) + } + } + + func removeItem(at path: String) throws { + log.append(.remove(path)) + lock.withLock { + _ = files.removeValue(forKey: path) + _ = fileAttributes.removeValue(forKey: path) + } + } + + func contents(at path: String) -> String? { + lock.withLock { files[path] } + } +} + +final class InstallationRecordingRunner: ProcessRunning, @unchecked Sendable { + private let result: ProcessResult + private let log: InstallationOperationLog + + init(result: ProcessResult, log: InstallationOperationLog) { + self.result = result + self.log = log + } + + func run(_ executable: String, arguments: [String]) throws -> ProcessResult { + log.append(.run(executable: executable, arguments: arguments)) + return result + } +} + +extension ProcessResult { + static let success = ProcessResult( + standardOutput: "", + standardError: "", + terminationStatus: 0 + ) +} From 081d7c675560dbe0e44397fadc6dda4b3c6b4c68 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:16:38 +0100 Subject: [PATCH 13/47] style(swift): adopt Google formatting --- .editorconfig | 19 + .gitattributes | 4 + .gitignore | 2 + .swift-format | 17 +- Package.swift | 62 +-- Sources/ClamshellCLI/ClamshellCommand.swift | 28 +- Sources/ClamshellCLI/CommandComposition.swift | 32 +- .../Commands/DisableCommand.swift | 18 +- .../ClamshellCLI/Commands/EnableCommand.swift | 18 +- .../ClamshellCLI/Commands/SetupCommand.swift | 30 +- .../ClamshellCLI/Commands/StatusCommand.swift | 24 +- .../ClamshellCLI/Commands/ToggleCommand.swift | 18 +- .../Commands/UninstallCommand.swift | 32 +- Sources/ClamshellCLI/Console.swift | 30 +- Sources/ClamshellCLI/OutputOptions.swift | 4 +- Sources/ClamshellCore/BuildVersion.swift | 4 +- .../ClamshellCore/Errors/ClamshellError.swift | 118 ++--- .../ClamshellCore/Power/PowerMutation.swift | 20 +- .../Power/PowerSettingsClient.swift | 58 +-- .../Power/PowerSettingsParser.swift | 76 +-- .../FoundationInstallationFileSystem.swift | 118 ++--- .../Installation/InstallationFileSystem.swift | 38 +- .../Installation/InstallationResult.swift | 24 +- .../Installation/PrivilegedInstallation.swift | 322 ++++++------ .../Installation/UninstallationResult.swift | 8 +- .../Privilege/PrivilegedHelperClient.swift | 36 +- .../Privilege/PrivilegedPaths.swift | 4 +- .../Privilege/SudoersPolicy.swift | 40 +- .../Process/FoundationProcessRunner.swift | 134 ++--- .../Process/ProcessOutputStream.swift | 4 +- .../ClamshellCore/Process/ProcessResult.swift | 24 +- .../Process/ProcessRunning.swift | 2 +- .../State/ClamshellService.swift | 86 ++-- .../ClamshellCore/State/ClamshellState.swift | 4 +- .../State/PowerStateReading.swift | 2 +- .../State/PowerStateWriting.swift | 2 +- .../State/TransitionResult.swift | 16 +- Sources/ClamshellHelper/ClamshellHelper.swift | 46 +- .../Commands/SetupCommandTests.swift | 40 +- .../BuildVersionTests.swift | 8 +- .../Power/PowerMutationTests.swift | 168 +++---- .../Power/PowerSettingsClientTests.swift | 122 ++--- .../Power/PowerSettingsParserTests.swift | 102 ++-- .../PrivilegedInstallationTests.swift | 470 +++++++++--------- .../PrivilegedHelperClientTests.swift | 122 ++--- .../Privilege/SudoersPolicyTests.swift | 72 +-- .../FoundationProcessRunnerTests.swift | 28 +- .../State/ClamshellServiceTests.swift | 168 +++---- .../Support/InstallationTestSupport.swift | 326 ++++++------ 49 files changed, 1592 insertions(+), 1558 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a2fecb8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.swift] +indent_style = space +indent_size = 2 +max_line_length = 100 + +[{*.json,*.yml,*.yaml}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ae5b536 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto eol=lf +*.icns binary +*.png binary +*.dmg binary diff --git a/.gitignore b/.gitignore index f7c5a9c..b22bad8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ .swiftpm/ .DS_Store xcuserdata/ +Clamshell.xcodeproj/ +DerivedData/ diff --git a/.swift-format b/.swift-format index 1e26525..4e33472 100644 --- a/.swift-format +++ b/.swift-format @@ -1,6 +1,15 @@ { - "indentation": { - "spaces": 4 - }, - "version": 1 + "indentation": { + "spaces": 2 + }, + "lineLength": 100, + "maximumBlankLines": 1, + "multiElementCollectionTrailingCommas": true, + "rules": { + "NeverForceUnwrap": true, + "NeverUseForceTry": true, + "NeverUseImplicitlyUnwrappedOptionals": true, + "OrderedImports": true + }, + "version": 1 } diff --git a/Package.swift b/Package.swift index 17b9459..1e9727e 100644 --- a/Package.swift +++ b/Package.swift @@ -3,35 +3,35 @@ import PackageDescription let package = Package( - name: "clamshellctl", - platforms: [.macOS(.v13)], - products: [ - .executable(name: "clamshellctl", targets: ["ClamshellCLI"]), - .executable(name: "clamshellctl-helper", targets: ["ClamshellHelper"]), - ], - dependencies: [ - .package( - url: "https://github.com/apple/swift-argument-parser", - from: "1.8.0" - ) - ], - targets: [ - .target(name: "ClamshellCore"), - .executableTarget( - name: "ClamshellCLI", - dependencies: [ - "ClamshellCore", - .product(name: "ArgumentParser", package: "swift-argument-parser"), - ] - ), - .executableTarget( - name: "ClamshellHelper", - dependencies: ["ClamshellCore"] - ), - .testTarget(name: "ClamshellCoreTests", dependencies: ["ClamshellCore"]), - .testTarget( - name: "ClamshellCLITests", - dependencies: ["ClamshellCLI", "ClamshellCore"] - ), - ] + name: "clamshellctl", + platforms: [.macOS(.v13)], + products: [ + .executable(name: "clamshellctl", targets: ["ClamshellCLI"]), + .executable(name: "clamshellctl-helper", targets: ["ClamshellHelper"]), + ], + dependencies: [ + .package( + url: "https://github.com/apple/swift-argument-parser", + from: "1.8.0" + ) + ], + targets: [ + .target(name: "ClamshellCore"), + .executableTarget( + name: "ClamshellCLI", + dependencies: [ + "ClamshellCore", + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ] + ), + .executableTarget( + name: "ClamshellHelper", + dependencies: ["ClamshellCore"] + ), + .testTarget(name: "ClamshellCoreTests", dependencies: ["ClamshellCore"]), + .testTarget( + name: "ClamshellCLITests", + dependencies: ["ClamshellCLI", "ClamshellCore"] + ), + ] ) diff --git a/Sources/ClamshellCLI/ClamshellCommand.swift b/Sources/ClamshellCLI/ClamshellCommand.swift index e475874..5222a51 100644 --- a/Sources/ClamshellCLI/ClamshellCommand.swift +++ b/Sources/ClamshellCLI/ClamshellCommand.swift @@ -3,18 +3,18 @@ import ClamshellCore @main struct ClamshellCommand: ParsableCommand { - static let configuration = CommandConfiguration( - commandName: "clamshellctl", - abstract: "Control battery clamshell mode on macOS.", - version: BuildVersion.current, - subcommands: [ - StatusCommand.self, - EnableCommand.self, - DisableCommand.self, - ToggleCommand.self, - SetupCommand.self, - UninstallCommand.self, - ], - defaultSubcommand: StatusCommand.self - ) + static let configuration = CommandConfiguration( + commandName: "clamshellctl", + abstract: "Control battery clamshell mode on macOS.", + version: BuildVersion.current, + subcommands: [ + StatusCommand.self, + EnableCommand.self, + DisableCommand.self, + ToggleCommand.self, + SetupCommand.self, + UninstallCommand.self, + ], + defaultSubcommand: StatusCommand.self + ) } diff --git a/Sources/ClamshellCLI/CommandComposition.swift b/Sources/ClamshellCLI/CommandComposition.swift index 435b7ff..9aa39ad 100644 --- a/Sources/ClamshellCLI/CommandComposition.swift +++ b/Sources/ClamshellCLI/CommandComposition.swift @@ -2,22 +2,22 @@ import ClamshellCore import Foundation enum CommandComposition { - static func clamshellService() -> ClamshellService { - let runner = FoundationProcessRunner() - return ClamshellService( - stateReader: PowerSettingsClient(runner: runner), - stateWriter: PrivilegedHelperClient(runner: runner) - ) - } + static func clamshellService() -> ClamshellService { + let runner = FoundationProcessRunner() + return ClamshellService( + stateReader: PowerSettingsClient(runner: runner), + stateWriter: PrivilegedHelperClient(runner: runner) + ) + } - static func privilegedInstallation() throws -> PrivilegedInstallation { - guard - let executablePath = Bundle.main.executableURL? - .resolvingSymlinksInPath() - .path - else { - throw ClamshellError.executablePathUnavailable - } - return PrivilegedInstallation(executablePath: executablePath) + static func privilegedInstallation() throws -> PrivilegedInstallation { + guard + let executablePath = Bundle.main.executableURL? + .resolvingSymlinksInPath() + .path + else { + throw ClamshellError.executablePathUnavailable } + return PrivilegedInstallation(executablePath: executablePath) + } } diff --git a/Sources/ClamshellCLI/Commands/DisableCommand.swift b/Sources/ClamshellCLI/Commands/DisableCommand.swift index 6489648..49eb7eb 100644 --- a/Sources/ClamshellCLI/Commands/DisableCommand.swift +++ b/Sources/ClamshellCLI/Commands/DisableCommand.swift @@ -1,15 +1,15 @@ import ArgumentParser struct DisableCommand: ParsableCommand { - static let configuration = CommandConfiguration( - commandName: "disable", - abstract: "Prevent clamshell mode while using battery power." - ) + static let configuration = CommandConfiguration( + commandName: "disable", + abstract: "Prevent clamshell mode while using battery power." + ) - @OptionGroup var output: OutputOptions + @OptionGroup var output: OutputOptions - func run() throws { - let result = try CommandComposition.clamshellService().set(.disabled) - Console(isQuiet: output.quiet).writeTransition(result) - } + func run() throws { + let result = try CommandComposition.clamshellService().set(.disabled) + Console(isQuiet: output.quiet).writeTransition(result) + } } diff --git a/Sources/ClamshellCLI/Commands/EnableCommand.swift b/Sources/ClamshellCLI/Commands/EnableCommand.swift index 38123ea..5a85d55 100644 --- a/Sources/ClamshellCLI/Commands/EnableCommand.swift +++ b/Sources/ClamshellCLI/Commands/EnableCommand.swift @@ -1,15 +1,15 @@ import ArgumentParser struct EnableCommand: ParsableCommand { - static let configuration = CommandConfiguration( - commandName: "enable", - abstract: "Allow clamshell mode while using battery power." - ) + static let configuration = CommandConfiguration( + commandName: "enable", + abstract: "Allow clamshell mode while using battery power." + ) - @OptionGroup var output: OutputOptions + @OptionGroup var output: OutputOptions - func run() throws { - let result = try CommandComposition.clamshellService().set(.enabled) - Console(isQuiet: output.quiet).writeTransition(result) - } + func run() throws { + let result = try CommandComposition.clamshellService().set(.enabled) + Console(isQuiet: output.quiet).writeTransition(result) + } } diff --git a/Sources/ClamshellCLI/Commands/SetupCommand.swift b/Sources/ClamshellCLI/Commands/SetupCommand.swift index eaa463c..3c02f3d 100644 --- a/Sources/ClamshellCLI/Commands/SetupCommand.swift +++ b/Sources/ClamshellCLI/Commands/SetupCommand.swift @@ -1,23 +1,23 @@ import ArgumentParser struct SetupCommand: ParsableCommand { - static let configuration = CommandConfiguration( - commandName: "setup", - abstract: "Install the restricted privileged helper." - ) + static let configuration = CommandConfiguration( + commandName: "setup", + abstract: "Install the restricted privileged helper." + ) - @OptionGroup var output: OutputOptions + @OptionGroup var output: OutputOptions - func run() throws { - let result = try CommandComposition.privilegedInstallation().install() - let console = Console(isQuiet: output.quiet) + func run() throws { + let result = try CommandComposition.privilegedInstallation().install() + let console = Console(isQuiet: output.quiet) - if result.didChange { - console.writeLine("Privileged setup installed:") - console.writeLine(result.helperPath) - console.writeLine(result.sudoersPolicyPath) - } else { - console.writeLine("Privileged setup: already configured") - } + if result.didChange { + console.writeLine("Privileged setup installed:") + console.writeLine(result.helperPath) + console.writeLine(result.sudoersPolicyPath) + } else { + console.writeLine("Privileged setup: already configured") } + } } diff --git a/Sources/ClamshellCLI/Commands/StatusCommand.swift b/Sources/ClamshellCLI/Commands/StatusCommand.swift index 58839a0..58f7342 100644 --- a/Sources/ClamshellCLI/Commands/StatusCommand.swift +++ b/Sources/ClamshellCLI/Commands/StatusCommand.swift @@ -2,18 +2,18 @@ import ArgumentParser import ClamshellCore struct StatusCommand: ParsableCommand { - static let configuration = CommandConfiguration( - commandName: "status", - abstract: "Show the current battery clamshell mode." - ) + static let configuration = CommandConfiguration( + commandName: "status", + abstract: "Show the current battery clamshell mode." + ) - @OptionGroup var output: OutputOptions + @OptionGroup var output: OutputOptions - func run() throws { - let client = PowerSettingsClient(runner: FoundationProcessRunner()) - let state = try client.currentState() - Console(isQuiet: output.quiet).writeLine( - "Battery clamshell mode: \(state.rawValue)" - ) - } + func run() throws { + let client = PowerSettingsClient(runner: FoundationProcessRunner()) + let state = try client.currentState() + Console(isQuiet: output.quiet).writeLine( + "Battery clamshell mode: \(state.rawValue)" + ) + } } diff --git a/Sources/ClamshellCLI/Commands/ToggleCommand.swift b/Sources/ClamshellCLI/Commands/ToggleCommand.swift index ea7dceb..0c66fe0 100644 --- a/Sources/ClamshellCLI/Commands/ToggleCommand.swift +++ b/Sources/ClamshellCLI/Commands/ToggleCommand.swift @@ -1,15 +1,15 @@ import ArgumentParser struct ToggleCommand: ParsableCommand { - static let configuration = CommandConfiguration( - commandName: "toggle", - abstract: "Switch the current battery clamshell mode." - ) + static let configuration = CommandConfiguration( + commandName: "toggle", + abstract: "Switch the current battery clamshell mode." + ) - @OptionGroup var output: OutputOptions + @OptionGroup var output: OutputOptions - func run() throws { - let result = try CommandComposition.clamshellService().toggle() - Console(isQuiet: output.quiet).writeTransition(result) - } + func run() throws { + let result = try CommandComposition.clamshellService().toggle() + Console(isQuiet: output.quiet).writeTransition(result) + } } diff --git a/Sources/ClamshellCLI/Commands/UninstallCommand.swift b/Sources/ClamshellCLI/Commands/UninstallCommand.swift index d520fa4..1b141ce 100644 --- a/Sources/ClamshellCLI/Commands/UninstallCommand.swift +++ b/Sources/ClamshellCLI/Commands/UninstallCommand.swift @@ -1,25 +1,25 @@ import ArgumentParser struct UninstallCommand: ParsableCommand { - static let configuration = CommandConfiguration( - commandName: "uninstall", - abstract: "Remove the restricted privileged helper." - ) + static let configuration = CommandConfiguration( + commandName: "uninstall", + abstract: "Remove the restricted privileged helper." + ) - @OptionGroup var output: OutputOptions + @OptionGroup var output: OutputOptions - func run() throws { - let result = try CommandComposition.privilegedInstallation().uninstall() - let console = Console(isQuiet: output.quiet) + func run() throws { + let result = try CommandComposition.privilegedInstallation().uninstall() + let console = Console(isQuiet: output.quiet) - guard !result.removedPaths.isEmpty else { - console.writeLine("Privileged setup: already removed") - return - } + guard !result.removedPaths.isEmpty else { + console.writeLine("Privileged setup: already removed") + return + } - console.writeLine("Privileged setup removed:") - for path in result.removedPaths { - console.writeLine(path) - } + console.writeLine("Privileged setup removed:") + for path in result.removedPaths { + console.writeLine(path) } + } } diff --git a/Sources/ClamshellCLI/Console.swift b/Sources/ClamshellCLI/Console.swift index 7023a6e..a02e6b1 100644 --- a/Sources/ClamshellCLI/Console.swift +++ b/Sources/ClamshellCLI/Console.swift @@ -2,23 +2,23 @@ import ClamshellCore import Foundation struct Console { - private let standardOutput: FileHandle - private let isQuiet: Bool + private let standardOutput: FileHandle + private let isQuiet: Bool - init(isQuiet: Bool, standardOutput: FileHandle = .standardOutput) { - self.isQuiet = isQuiet - self.standardOutput = standardOutput - } + init(isQuiet: Bool, standardOutput: FileHandle = .standardOutput) { + self.isQuiet = isQuiet + self.standardOutput = standardOutput + } - func writeLine(_ line: String) { - guard !isQuiet else { - return - } - standardOutput.write(Data("\(line)\n".utf8)) + func writeLine(_ line: String) { + guard !isQuiet else { + return } + standardOutput.write(Data("\(line)\n".utf8)) + } - func writeTransition(_ result: TransitionResult) { - let qualifier = result.didChange ? "" : "already " - writeLine("Battery clamshell mode: \(qualifier)\(result.current.rawValue)") - } + func writeTransition(_ result: TransitionResult) { + let qualifier = result.didChange ? "" : "already " + writeLine("Battery clamshell mode: \(qualifier)\(result.current.rawValue)") + } } diff --git a/Sources/ClamshellCLI/OutputOptions.swift b/Sources/ClamshellCLI/OutputOptions.swift index f3d88ed..40d8362 100644 --- a/Sources/ClamshellCLI/OutputOptions.swift +++ b/Sources/ClamshellCLI/OutputOptions.swift @@ -1,6 +1,6 @@ import ArgumentParser struct OutputOptions: ParsableArguments { - @Flag(name: .long, help: "Suppress successful output.") - var quiet = false + @Flag(name: .long, help: "Suppress successful output.") + var quiet = false } diff --git a/Sources/ClamshellCore/BuildVersion.swift b/Sources/ClamshellCore/BuildVersion.swift index 3c52678..8d7e96b 100644 --- a/Sources/ClamshellCore/BuildVersion.swift +++ b/Sources/ClamshellCore/BuildVersion.swift @@ -1,4 +1,4 @@ public enum BuildVersion { - // x-release-please-version - public static let current = "0.1.0" + // x-release-please-version + public static let current = "0.1.0" } diff --git a/Sources/ClamshellCore/Errors/ClamshellError.swift b/Sources/ClamshellCore/Errors/ClamshellError.swift index 01b7349..db4420e 100644 --- a/Sources/ClamshellCore/Errors/ClamshellError.swift +++ b/Sources/ClamshellCore/Errors/ClamshellError.swift @@ -1,66 +1,66 @@ import Foundation public enum ClamshellError: Error, Equatable, LocalizedError { - case administratorPrivilegesRequired(command: String) - case executablePathUnavailable - case helperPayloadNotFound - case installationVerificationFailed - case invalidHelperArguments - case invalidProcessOutput(executable: String, stream: ProcessOutputStream) - case invalidUsername(String) - case originalUserUnavailable - case privilegedHelperUnavailable(setupCommand: String) - case processFailed( - executable: String, - terminationStatus: Int32, - standardError: String - ) - case stateVerificationFailed(expected: ClamshellState, actual: ClamshellState) - case sudoersValidationFailed - case unrecognisedPowerSettings + case administratorPrivilegesRequired(command: String) + case executablePathUnavailable + case helperPayloadNotFound + case installationVerificationFailed + case invalidHelperArguments + case invalidProcessOutput(executable: String, stream: ProcessOutputStream) + case invalidUsername(String) + case originalUserUnavailable + case privilegedHelperUnavailable(setupCommand: String) + case processFailed( + executable: String, + terminationStatus: Int32, + standardError: String + ) + case stateVerificationFailed(expected: ClamshellState, actual: ClamshellState) + case sudoersValidationFailed + case unrecognisedPowerSettings - public var errorDescription: String? { - switch self { - case .administratorPrivilegesRequired(let command): - "Administrator privileges are required. Run: \(command)" - case .executablePathUnavailable: - "Unable to resolve the clamshellctl executable path." - case .helperPayloadNotFound: - "Unable to locate the clamshellctl helper payload." - case .installationVerificationFailed: - "The privileged installation could not be verified." - case .invalidHelperArguments: - "Invalid privileged helper arguments." - case .invalidProcessOutput(let executable, let stream): - "Unable to decode \(stream.rawValue) from \(executable)." - case .invalidUsername: - "The original account name is unavailable or unsafe." - case .originalUserUnavailable: - "Unable to identify the account that requested setup." - case .privilegedHelperUnavailable(let setupCommand): - "Privileged helper unavailable. Run: \(setupCommand)" - case .processFailed(let executable, let terminationStatus, let standardError): - processFailureDescription( - executable: executable, - terminationStatus: terminationStatus, - standardError: standardError - ) - case .stateVerificationFailed(let expected, let actual): - "Battery clamshell mode verification failed: expected \(expected.rawValue), found \(actual.rawValue)." - case .sudoersValidationFailed: - "The generated sudoers policy failed validation." - case .unrecognisedPowerSettings: - "Unable to read the Battery Power section from pmset." - } + public var errorDescription: String? { + switch self { + case .administratorPrivilegesRequired(let command): + "Administrator privileges are required. Run: \(command)" + case .executablePathUnavailable: + "Unable to resolve the clamshellctl executable path." + case .helperPayloadNotFound: + "Unable to locate the clamshellctl helper payload." + case .installationVerificationFailed: + "The privileged installation could not be verified." + case .invalidHelperArguments: + "Invalid privileged helper arguments." + case .invalidProcessOutput(let executable, let stream): + "Unable to decode \(stream.rawValue) from \(executable)." + case .invalidUsername: + "The original account name is unavailable or unsafe." + case .originalUserUnavailable: + "Unable to identify the account that requested setup." + case .privilegedHelperUnavailable(let setupCommand): + "Privileged helper unavailable. Run: \(setupCommand)" + case .processFailed(let executable, let terminationStatus, let standardError): + processFailureDescription( + executable: executable, + terminationStatus: terminationStatus, + standardError: standardError + ) + case .stateVerificationFailed(let expected, let actual): + "Battery clamshell mode verification failed: expected \(expected.rawValue), found \(actual.rawValue)." + case .sudoersValidationFailed: + "The generated sudoers policy failed validation." + case .unrecognisedPowerSettings: + "Unable to read the Battery Power section from pmset." } + } - private func processFailureDescription( - executable: String, - terminationStatus: Int32, - standardError: String - ) -> String { - let detail = standardError.trimmingCharacters(in: .whitespacesAndNewlines) - let prefix = "\(executable) exited with status \(terminationStatus)." - return detail.isEmpty ? prefix : "\(prefix) \(detail)" - } + private func processFailureDescription( + executable: String, + terminationStatus: Int32, + standardError: String + ) -> String { + let detail = standardError.trimmingCharacters(in: .whitespacesAndNewlines) + let prefix = "\(executable) exited with status \(terminationStatus)." + return detail.isEmpty ? prefix : "\(prefix) \(detail)" + } } diff --git a/Sources/ClamshellCore/Power/PowerMutation.swift b/Sources/ClamshellCore/Power/PowerMutation.swift index ec5770e..9d45f50 100644 --- a/Sources/ClamshellCore/Power/PowerMutation.swift +++ b/Sources/ClamshellCore/Power/PowerMutation.swift @@ -1,14 +1,14 @@ public struct PowerMutation: Sendable, Equatable { - public let state: ClamshellState + public let state: ClamshellState - public init(rawArguments: [String]) throws { - switch rawArguments { - case ["enable"]: - state = .enabled - case ["disable"]: - state = .disabled - default: - throw ClamshellError.invalidHelperArguments - } + public init(rawArguments: [String]) throws { + switch rawArguments { + case ["enable"]: + state = .enabled + case ["disable"]: + state = .disabled + default: + throw ClamshellError.invalidHelperArguments } + } } diff --git a/Sources/ClamshellCore/Power/PowerSettingsClient.swift b/Sources/ClamshellCore/Power/PowerSettingsClient.swift index fbc4974..7acb7bd 100644 --- a/Sources/ClamshellCore/Power/PowerSettingsClient.swift +++ b/Sources/ClamshellCore/Power/PowerSettingsClient.swift @@ -1,38 +1,38 @@ public struct PowerSettingsClient: PowerStateReading, PowerStateWriting, Sendable { - private let runner: any ProcessRunning - private let parser: PowerSettingsParser + private let runner: any ProcessRunning + private let parser: PowerSettingsParser - public init( - runner: any ProcessRunning, - parser: PowerSettingsParser = PowerSettingsParser() - ) { - self.runner = runner - self.parser = parser - } + public init( + runner: any ProcessRunning, + parser: PowerSettingsParser = PowerSettingsParser() + ) { + self.runner = runner + self.parser = parser + } - public func currentState() throws -> ClamshellState { - let result = try runPmset(arguments: ["-g", "custom"]) + public func currentState() throws -> ClamshellState { + let result = try runPmset(arguments: ["-g", "custom"]) - return try parser.batteryState(from: result.standardOutput) - } + return try parser.batteryState(from: result.standardOutput) + } - public func setState(_ state: ClamshellState) throws { - let value = state == .enabled ? "1" : "0" - _ = try runPmset(arguments: ["-b", "disablesleep", value]) - } + public func setState(_ state: ClamshellState) throws { + let value = state == .enabled ? "1" : "0" + _ = try runPmset(arguments: ["-b", "disablesleep", value]) + } - private func runPmset(arguments: [String]) throws -> ProcessResult { - let executable = "/usr/bin/pmset" - let result = try runner.run(executable, arguments: arguments) + private func runPmset(arguments: [String]) throws -> ProcessResult { + let executable = "/usr/bin/pmset" + let result = try runner.run(executable, arguments: arguments) - guard result.terminationStatus == 0 else { - throw ClamshellError.processFailed( - executable: executable, - terminationStatus: result.terminationStatus, - standardError: result.standardError - ) - } - - return result + guard result.terminationStatus == 0 else { + throw ClamshellError.processFailed( + executable: executable, + terminationStatus: result.terminationStatus, + standardError: result.standardError + ) } + + return result + } } diff --git a/Sources/ClamshellCore/Power/PowerSettingsParser.swift b/Sources/ClamshellCore/Power/PowerSettingsParser.swift index 3d97095..45a0139 100644 --- a/Sources/ClamshellCore/Power/PowerSettingsParser.swift +++ b/Sources/ClamshellCore/Power/PowerSettingsParser.swift @@ -1,49 +1,49 @@ import Foundation public struct PowerSettingsParser: Sendable { - public init() {} + public init() {} - public func batteryState(from output: String) throws -> ClamshellState { - let lines = output.split( - omittingEmptySubsequences: false, - whereSeparator: \Character.isNewline - ) + public func batteryState(from output: String) throws -> ClamshellState { + let lines = output.split( + omittingEmptySubsequences: false, + whereSeparator: \Character.isNewline + ) - guard - let batteryHeader = lines.firstIndex(where: { - $0.trimmingCharacters(in: .whitespaces) == "Battery Power:" - }) - else { - throw ClamshellError.unrecognisedPowerSettings - } - - for line in lines[lines.index(after: batteryHeader)...] { - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty else { - continue - } - guard line.first?.isWhitespace == true else { - break - } + guard + let batteryHeader = lines.firstIndex(where: { + $0.trimmingCharacters(in: .whitespaces) == "Battery Power:" + }) + else { + throw ClamshellError.unrecognisedPowerSettings + } - let fields = trimmed.split(whereSeparator: \Character.isWhitespace) - guard fields.first == "disablesleep" else { - continue - } - guard fields.count == 2 else { - throw ClamshellError.unrecognisedPowerSettings - } + for line in lines[lines.index(after: batteryHeader)...] { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { + continue + } + guard line.first?.isWhitespace == true else { + break + } - switch fields[1] { - case "1": - return .enabled - case "0": - return .disabled - default: - throw ClamshellError.unrecognisedPowerSettings - } - } + let fields = trimmed.split(whereSeparator: \Character.isWhitespace) + guard fields.first == "disablesleep" else { + continue + } + guard fields.count == 2 else { + throw ClamshellError.unrecognisedPowerSettings + } + switch fields[1] { + case "1": + return .enabled + case "0": return .disabled + default: + throw ClamshellError.unrecognisedPowerSettings + } } + + return .disabled + } } diff --git a/Sources/ClamshellCore/Privilege/Installation/FoundationInstallationFileSystem.swift b/Sources/ClamshellCore/Privilege/Installation/FoundationInstallationFileSystem.swift index 6852f0e..ded6964 100644 --- a/Sources/ClamshellCore/Privilege/Installation/FoundationInstallationFileSystem.swift +++ b/Sources/ClamshellCore/Privilege/Installation/FoundationInstallationFileSystem.swift @@ -2,81 +2,81 @@ import Darwin import Foundation public struct FoundationInstallationFileSystem: InstallationFileSystem { - public init() {} + public init() {} - public func itemExists(at path: String) -> Bool { - var information = stat() - return path.withCString { lstat($0, &information) == 0 } - } + public func itemExists(at path: String) -> Bool { + var information = stat() + return path.withCString { lstat($0, &information) == 0 } + } - public func isRegularFile(at path: String) -> Bool { - guard - let attributes = try? FileManager.default.attributesOfItem(atPath: path), - let type = attributes[.type] as? FileAttributeType - else { - return false - } - return type == .typeRegular + public func isRegularFile(at path: String) -> Bool { + guard + let attributes = try? FileManager.default.attributesOfItem(atPath: path), + let type = attributes[.type] as? FileAttributeType + else { + return false } + return type == .typeRegular + } - public func contentsEqual(at firstPath: String, and secondPath: String) -> Bool { - FileManager.default.contentsEqual(atPath: firstPath, andPath: secondPath) - } + public func contentsEqual(at firstPath: String, and secondPath: String) -> Bool { + FileManager.default.contentsEqual(atPath: firstPath, andPath: secondPath) + } - public func readText(at path: String) throws -> String { - try String(contentsOfFile: path, encoding: .utf8) - } + public func readText(at path: String) throws -> String { + try String(contentsOfFile: path, encoding: .utf8) + } - public func attributes(at path: String) throws -> InstalledFileAttributes { - let attributes = try FileManager.default.attributesOfItem(atPath: path) - guard - let userID = attributes[.ownerAccountID] as? NSNumber, - let groupID = attributes[.groupOwnerAccountID] as? NSNumber, - let permissions = attributes[.posixPermissions] as? NSNumber - else { - throw ClamshellError.installationVerificationFailed - } - return InstalledFileAttributes( - userID: userID.uint32Value, - groupID: groupID.uint32Value, - permissions: permissions.uint16Value - ) + public func attributes(at path: String) throws -> InstalledFileAttributes { + let attributes = try FileManager.default.attributesOfItem(atPath: path) + guard + let userID = attributes[.ownerAccountID] as? NSNumber, + let groupID = attributes[.groupOwnerAccountID] as? NSNumber, + let permissions = attributes[.posixPermissions] as? NSNumber + else { + throw ClamshellError.installationVerificationFailed } + return InstalledFileAttributes( + userID: userID.uint32Value, + groupID: groupID.uint32Value, + permissions: permissions.uint16Value + ) + } - public func copyItem(at source: String, to destination: String) throws { - try FileManager.default.copyItem(atPath: source, toPath: destination) - } + public func copyItem(at source: String, to destination: String) throws { + try FileManager.default.copyItem(atPath: source, toPath: destination) + } - public func write(_ contents: String, to path: String) throws { - try Data(contents.utf8).write(to: URL(fileURLWithPath: path)) - } + public func write(_ contents: String, to path: String) throws { + try Data(contents.utf8).write(to: URL(fileURLWithPath: path)) + } - public func setOwner(userID: UInt32, groupID: UInt32, at path: String) throws { - guard chown(path, userID, groupID) == 0 else { - throw currentPOSIXError() - } + public func setOwner(userID: UInt32, groupID: UInt32, at path: String) throws { + guard chown(path, userID, groupID) == 0 else { + throw currentPOSIXError() } + } - public func setPermissions(_ permissions: UInt16, at path: String) throws { - guard chmod(path, mode_t(permissions)) == 0 else { - throw currentPOSIXError() - } + public func setPermissions(_ permissions: UInt16, at path: String) throws { + guard chmod(path, mode_t(permissions)) == 0 else { + throw currentPOSIXError() } + } - public func replaceItem(at destination: String, withItemAt replacement: String) throws { - guard rename(replacement, destination) == 0 else { - throw currentPOSIXError() - } + public func replaceItem(at destination: String, withItemAt replacement: String) throws { + guard rename(replacement, destination) == 0 else { + throw currentPOSIXError() } + } - public func removeItem(at path: String) throws { - guard itemExists(at: path) else { - return - } - try FileManager.default.removeItem(atPath: path) + public func removeItem(at path: String) throws { + guard itemExists(at: path) else { + return } + try FileManager.default.removeItem(atPath: path) + } - private func currentPOSIXError() -> POSIXError { - POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) - } + private func currentPOSIXError() -> POSIXError { + POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } } diff --git a/Sources/ClamshellCore/Privilege/Installation/InstallationFileSystem.swift b/Sources/ClamshellCore/Privilege/Installation/InstallationFileSystem.swift index b023107..5e08a7d 100644 --- a/Sources/ClamshellCore/Privilege/Installation/InstallationFileSystem.swift +++ b/Sources/ClamshellCore/Privilege/Installation/InstallationFileSystem.swift @@ -1,25 +1,25 @@ public protocol InstallationFileSystem: Sendable { - func itemExists(at path: String) -> Bool - func isRegularFile(at path: String) -> Bool - func contentsEqual(at firstPath: String, and secondPath: String) -> Bool - func readText(at path: String) throws -> String - func attributes(at path: String) throws -> InstalledFileAttributes - func copyItem(at source: String, to destination: String) throws - func write(_ contents: String, to path: String) throws - func setOwner(userID: UInt32, groupID: UInt32, at path: String) throws - func setPermissions(_ permissions: UInt16, at path: String) throws - func replaceItem(at destination: String, withItemAt replacement: String) throws - func removeItem(at path: String) throws + func itemExists(at path: String) -> Bool + func isRegularFile(at path: String) -> Bool + func contentsEqual(at firstPath: String, and secondPath: String) -> Bool + func readText(at path: String) throws -> String + func attributes(at path: String) throws -> InstalledFileAttributes + func copyItem(at source: String, to destination: String) throws + func write(_ contents: String, to path: String) throws + func setOwner(userID: UInt32, groupID: UInt32, at path: String) throws + func setPermissions(_ permissions: UInt16, at path: String) throws + func replaceItem(at destination: String, withItemAt replacement: String) throws + func removeItem(at path: String) throws } public struct InstalledFileAttributes: Sendable, Equatable { - public let userID: UInt32 - public let groupID: UInt32 - public let permissions: UInt16 + public let userID: UInt32 + public let groupID: UInt32 + public let permissions: UInt16 - public init(userID: UInt32, groupID: UInt32, permissions: UInt16) { - self.userID = userID - self.groupID = groupID - self.permissions = permissions - } + public init(userID: UInt32, groupID: UInt32, permissions: UInt16) { + self.userID = userID + self.groupID = groupID + self.permissions = permissions + } } diff --git a/Sources/ClamshellCore/Privilege/Installation/InstallationResult.swift b/Sources/ClamshellCore/Privilege/Installation/InstallationResult.swift index 4e728ce..f3138da 100644 --- a/Sources/ClamshellCore/Privilege/Installation/InstallationResult.swift +++ b/Sources/ClamshellCore/Privilege/Installation/InstallationResult.swift @@ -1,15 +1,15 @@ public struct InstallationResult: Sendable, Equatable { - public let helperPath: String - public let sudoersPolicyPath: String - public let didChange: Bool + public let helperPath: String + public let sudoersPolicyPath: String + public let didChange: Bool - public init( - helperPath: String, - sudoersPolicyPath: String, - didChange: Bool = true - ) { - self.helperPath = helperPath - self.sudoersPolicyPath = sudoersPolicyPath - self.didChange = didChange - } + public init( + helperPath: String, + sudoersPolicyPath: String, + didChange: Bool = true + ) { + self.helperPath = helperPath + self.sudoersPolicyPath = sudoersPolicyPath + self.didChange = didChange + } } diff --git a/Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift b/Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift index e4cc0b6..d112b37 100644 --- a/Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift +++ b/Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift @@ -2,180 +2,180 @@ import Darwin import Foundation public struct PrivilegedInstallation: Sendable { - private let fileSystem: any InstallationFileSystem - private let runner: any ProcessRunning - private let effectiveUserID: UInt32 - private let environment: [String: String] - private let executablePath: String - private let temporarySuffix: String - - public init( - fileSystem: any InstallationFileSystem, - runner: any ProcessRunning, - effectiveUserID: UInt32, - environment: [String: String], - executablePath: String, - temporarySuffix: String = UUID().uuidString - ) { - self.fileSystem = fileSystem - self.runner = runner - self.effectiveUserID = effectiveUserID - self.environment = environment - self.executablePath = executablePath - self.temporarySuffix = temporarySuffix + private let fileSystem: any InstallationFileSystem + private let runner: any ProcessRunning + private let effectiveUserID: UInt32 + private let environment: [String: String] + private let executablePath: String + private let temporarySuffix: String + + public init( + fileSystem: any InstallationFileSystem, + runner: any ProcessRunning, + effectiveUserID: UInt32, + environment: [String: String], + executablePath: String, + temporarySuffix: String = UUID().uuidString + ) { + self.fileSystem = fileSystem + self.runner = runner + self.effectiveUserID = effectiveUserID + self.environment = environment + self.executablePath = executablePath + self.temporarySuffix = temporarySuffix + } + + public init(executablePath: String) { + self.init( + fileSystem: FoundationInstallationFileSystem(), + runner: FoundationProcessRunner(), + effectiveUserID: geteuid(), + environment: ProcessInfo.processInfo.environment, + executablePath: executablePath + ) + } + + public func install() throws -> InstallationResult { + try requireAdministratorPrivileges() + + guard let originalUser = environment["SUDO_USER"], !originalUser.isEmpty else { + throw ClamshellError.originalUserUnavailable } - - public init(executablePath: String) { - self.init( - fileSystem: FoundationInstallationFileSystem(), - runner: FoundationProcessRunner(), - effectiveUserID: geteuid(), - environment: ProcessInfo.processInfo.environment, - executablePath: executablePath - ) + let policy = try SudoersPolicy(username: originalUser) + let payload = try helperPayloadPath() + + if try isConfigured(payload: payload, policy: policy) { + return InstallationResult( + helperPath: PrivilegedPaths.helper, + sudoersPolicyPath: PrivilegedPaths.sudoersPolicy, + didChange: false + ) } - public func install() throws -> InstallationResult { - try requireAdministratorPrivileges() - - guard let originalUser = environment["SUDO_USER"], !originalUser.isEmpty else { - throw ClamshellError.originalUserUnavailable - } - let policy = try SudoersPolicy(username: originalUser) - let payload = try helperPayloadPath() - - if try isConfigured(payload: payload, policy: policy) { - return InstallationResult( - helperPath: PrivilegedPaths.helper, - sudoersPolicyPath: PrivilegedPaths.sudoersPolicy, - didChange: false - ) - } - - let helperTemporary = temporaryPath(for: PrivilegedPaths.helper) - var helperTemporaryExists = false - defer { - if helperTemporaryExists { - try? fileSystem.removeItem(at: helperTemporary) - } - } - - try fileSystem.copyItem(at: payload, to: helperTemporary) - helperTemporaryExists = true - try fileSystem.setOwner(userID: 0, groupID: 0, at: helperTemporary) - try fileSystem.setPermissions(0o755, at: helperTemporary) - try fileSystem.replaceItem( - at: PrivilegedPaths.helper, - withItemAt: helperTemporary - ) - helperTemporaryExists = false - - let policyTemporary = temporaryPath(for: PrivilegedPaths.sudoersPolicy) - var policyTemporaryExists = false - defer { - if policyTemporaryExists { - try? fileSystem.removeItem(at: policyTemporary) - } - } - - try fileSystem.write(policy.contents, to: policyTemporary) - policyTemporaryExists = true - try fileSystem.setOwner(userID: 0, groupID: 0, at: policyTemporary) - try fileSystem.setPermissions(0o440, at: policyTemporary) - - let validation = try runner.run( - "/usr/sbin/visudo", - arguments: ["-cf", policyTemporary] - ) - guard validation.terminationStatus == 0 else { - throw ClamshellError.sudoersValidationFailed - } - - try fileSystem.replaceItem( - at: PrivilegedPaths.sudoersPolicy, - withItemAt: policyTemporary - ) - policyTemporaryExists = false - - guard try isConfigured(payload: payload, policy: policy) else { - throw ClamshellError.installationVerificationFailed - } - - return InstallationResult( - helperPath: PrivilegedPaths.helper, - sudoersPolicyPath: PrivilegedPaths.sudoersPolicy, - didChange: true - ) + let helperTemporary = temporaryPath(for: PrivilegedPaths.helper) + var helperTemporaryExists = false + defer { + if helperTemporaryExists { + try? fileSystem.removeItem(at: helperTemporary) + } } - public func uninstall() throws -> UninstallationResult { - try requireAdministratorPrivileges(command: PrivilegedHelperClient.uninstallCommand) - - var removedPaths: [String] = [] - for path in [PrivilegedPaths.helper, PrivilegedPaths.sudoersPolicy] - where fileSystem.itemExists(at: path) { - try fileSystem.removeItem(at: path) - removedPaths.append(path) - } - return UninstallationResult(removedPaths: removedPaths) + try fileSystem.copyItem(at: payload, to: helperTemporary) + helperTemporaryExists = true + try fileSystem.setOwner(userID: 0, groupID: 0, at: helperTemporary) + try fileSystem.setPermissions(0o755, at: helperTemporary) + try fileSystem.replaceItem( + at: PrivilegedPaths.helper, + withItemAt: helperTemporary + ) + helperTemporaryExists = false + + let policyTemporary = temporaryPath(for: PrivilegedPaths.sudoersPolicy) + var policyTemporaryExists = false + defer { + if policyTemporaryExists { + try? fileSystem.removeItem(at: policyTemporary) + } } - private func requireAdministratorPrivileges() throws { - try requireAdministratorPrivileges(command: PrivilegedHelperClient.setupCommand) + try fileSystem.write(policy.contents, to: policyTemporary) + policyTemporaryExists = true + try fileSystem.setOwner(userID: 0, groupID: 0, at: policyTemporary) + try fileSystem.setPermissions(0o440, at: policyTemporary) + + let validation = try runner.run( + "/usr/sbin/visudo", + arguments: ["-cf", policyTemporary] + ) + guard validation.terminationStatus == 0 else { + throw ClamshellError.sudoersValidationFailed } - private func requireAdministratorPrivileges(command: String) throws { - guard effectiveUserID == 0 else { - throw ClamshellError.administratorPrivilegesRequired( - command: command - ) - } - } + try fileSystem.replaceItem( + at: PrivilegedPaths.sudoersPolicy, + withItemAt: policyTemporary + ) + policyTemporaryExists = false - private func helperPayloadPath() throws -> String { - let executable = URL(fileURLWithPath: executablePath).standardizedFileURL - let executableDirectory = executable.deletingLastPathComponent() - var candidates = [ - executableDirectory.appendingPathComponent("clamshellctl-helper").path - ] - - if executableDirectory.lastPathComponent == "bin" { - candidates.append( - executableDirectory - .deletingLastPathComponent() - .appendingPathComponent("libexec/clamshellctl-helper") - .path - ) - } - - guard let payload = candidates.first(where: fileSystem.isRegularFile(at:)) else { - throw ClamshellError.helperPayloadNotFound - } - return payload + guard try isConfigured(payload: payload, policy: policy) else { + throw ClamshellError.installationVerificationFailed } - private func temporaryPath(for destination: String) -> String { - "\(destination).installing.\(temporarySuffix)" + return InstallationResult( + helperPath: PrivilegedPaths.helper, + sudoersPolicyPath: PrivilegedPaths.sudoersPolicy, + didChange: true + ) + } + + public func uninstall() throws -> UninstallationResult { + try requireAdministratorPrivileges(command: PrivilegedHelperClient.uninstallCommand) + + var removedPaths: [String] = [] + for path in [PrivilegedPaths.helper, PrivilegedPaths.sudoersPolicy] + where fileSystem.itemExists(at: path) { + try fileSystem.removeItem(at: path) + removedPaths.append(path) + } + return UninstallationResult(removedPaths: removedPaths) + } + + private func requireAdministratorPrivileges() throws { + try requireAdministratorPrivileges(command: PrivilegedHelperClient.setupCommand) + } + + private func requireAdministratorPrivileges(command: String) throws { + guard effectiveUserID == 0 else { + throw ClamshellError.administratorPrivilegesRequired( + command: command + ) + } + } + + private func helperPayloadPath() throws -> String { + let executable = URL(fileURLWithPath: executablePath).standardizedFileURL + let executableDirectory = executable.deletingLastPathComponent() + var candidates = [ + executableDirectory.appendingPathComponent("clamshellctl-helper").path + ] + + if executableDirectory.lastPathComponent == "bin" { + candidates.append( + executableDirectory + .deletingLastPathComponent() + .appendingPathComponent("libexec/clamshellctl-helper") + .path + ) } - private func isConfigured(payload: String, policy: SudoersPolicy) throws -> Bool { - guard - fileSystem.isRegularFile(at: PrivilegedPaths.helper), - fileSystem.contentsEqual(at: payload, and: PrivilegedPaths.helper), - try fileSystem.attributes(at: PrivilegedPaths.helper) - == InstalledFileAttributes(userID: 0, groupID: 0, permissions: 0o755), - fileSystem.isRegularFile(at: PrivilegedPaths.sudoersPolicy), - try fileSystem.readText(at: PrivilegedPaths.sudoersPolicy) == policy.contents, - try fileSystem.attributes(at: PrivilegedPaths.sudoersPolicy) - == InstalledFileAttributes(userID: 0, groupID: 0, permissions: 0o440) - else { - return false - } - - let validation = try runner.run( - "/usr/sbin/visudo", - arguments: ["-cf", PrivilegedPaths.sudoersPolicy] - ) - return validation.terminationStatus == 0 + guard let payload = candidates.first(where: fileSystem.isRegularFile(at:)) else { + throw ClamshellError.helperPayloadNotFound + } + return payload + } + + private func temporaryPath(for destination: String) -> String { + "\(destination).installing.\(temporarySuffix)" + } + + private func isConfigured(payload: String, policy: SudoersPolicy) throws -> Bool { + guard + fileSystem.isRegularFile(at: PrivilegedPaths.helper), + fileSystem.contentsEqual(at: payload, and: PrivilegedPaths.helper), + try fileSystem.attributes(at: PrivilegedPaths.helper) + == InstalledFileAttributes(userID: 0, groupID: 0, permissions: 0o755), + fileSystem.isRegularFile(at: PrivilegedPaths.sudoersPolicy), + try fileSystem.readText(at: PrivilegedPaths.sudoersPolicy) == policy.contents, + try fileSystem.attributes(at: PrivilegedPaths.sudoersPolicy) + == InstalledFileAttributes(userID: 0, groupID: 0, permissions: 0o440) + else { + return false } + + let validation = try runner.run( + "/usr/sbin/visudo", + arguments: ["-cf", PrivilegedPaths.sudoersPolicy] + ) + return validation.terminationStatus == 0 + } } diff --git a/Sources/ClamshellCore/Privilege/Installation/UninstallationResult.swift b/Sources/ClamshellCore/Privilege/Installation/UninstallationResult.swift index c0d6836..12c96a2 100644 --- a/Sources/ClamshellCore/Privilege/Installation/UninstallationResult.swift +++ b/Sources/ClamshellCore/Privilege/Installation/UninstallationResult.swift @@ -1,7 +1,7 @@ public struct UninstallationResult: Sendable, Equatable { - public let removedPaths: [String] + public let removedPaths: [String] - public init(removedPaths: [String]) { - self.removedPaths = removedPaths - } + public init(removedPaths: [String]) { + self.removedPaths = removedPaths + } } diff --git a/Sources/ClamshellCore/Privilege/PrivilegedHelperClient.swift b/Sources/ClamshellCore/Privilege/PrivilegedHelperClient.swift index 76ab387..3881409 100644 --- a/Sources/ClamshellCore/Privilege/PrivilegedHelperClient.swift +++ b/Sources/ClamshellCore/Privilege/PrivilegedHelperClient.swift @@ -1,25 +1,25 @@ public struct PrivilegedHelperClient: PowerStateWriting, Sendable { - public static let setupCommand = #"sudo "$(brew --prefix)/bin/clamshellctl" setup"# - public static let uninstallCommand = #"sudo "$(brew --prefix)/bin/clamshellctl" uninstall"# + public static let setupCommand = #"sudo "$(brew --prefix)/bin/clamshellctl" setup"# + public static let uninstallCommand = #"sudo "$(brew --prefix)/bin/clamshellctl" uninstall"# - private static let executable = "/usr/bin/sudo" - private let runner: any ProcessRunning + private static let executable = "/usr/bin/sudo" + private let runner: any ProcessRunning - public init(runner: any ProcessRunning) { - self.runner = runner - } + public init(runner: any ProcessRunning) { + self.runner = runner + } - public func setState(_ state: ClamshellState) throws { - let action = state == .enabled ? "enable" : "disable" - let result = try runner.run( - Self.executable, - arguments: ["-n", PrivilegedPaths.helper, action] - ) + public func setState(_ state: ClamshellState) throws { + let action = state == .enabled ? "enable" : "disable" + let result = try runner.run( + Self.executable, + arguments: ["-n", PrivilegedPaths.helper, action] + ) - guard result.terminationStatus == 0 else { - throw ClamshellError.privilegedHelperUnavailable( - setupCommand: Self.setupCommand - ) - } + guard result.terminationStatus == 0 else { + throw ClamshellError.privilegedHelperUnavailable( + setupCommand: Self.setupCommand + ) } + } } diff --git a/Sources/ClamshellCore/Privilege/PrivilegedPaths.swift b/Sources/ClamshellCore/Privilege/PrivilegedPaths.swift index 3ccb630..9036e70 100644 --- a/Sources/ClamshellCore/Privilege/PrivilegedPaths.swift +++ b/Sources/ClamshellCore/Privilege/PrivilegedPaths.swift @@ -1,4 +1,4 @@ public enum PrivilegedPaths { - public static let helper = "/Library/PrivilegedHelperTools/clamshellctl-helper" - public static let sudoersPolicy = "/etc/sudoers.d/clamshellctl" + public static let helper = "/Library/PrivilegedHelperTools/clamshellctl-helper" + public static let sudoersPolicy = "/etc/sudoers.d/clamshellctl" } diff --git a/Sources/ClamshellCore/Privilege/SudoersPolicy.swift b/Sources/ClamshellCore/Privilege/SudoersPolicy.swift index 2bd8ce0..9f19fa8 100644 --- a/Sources/ClamshellCore/Privilege/SudoersPolicy.swift +++ b/Sources/ClamshellCore/Privilege/SudoersPolicy.swift @@ -1,28 +1,28 @@ public struct SudoersPolicy: Sendable, Equatable { - public let username: String + public let username: String - public init(username: String) throws { - guard username != "root", !username.isEmpty, username.unicodeScalars.allSatisfy(Self.isSafe) - else { - throw ClamshellError.invalidUsername(username) - } - self.username = username + public init(username: String) throws { + guard username != "root", !username.isEmpty, username.unicodeScalars.allSatisfy(Self.isSafe) + else { + throw ClamshellError.invalidUsername(username) } + self.username = username + } - public var contents: String { - """ - \(username) ALL=(root) NOPASSWD: \(PrivilegedPaths.helper) enable - \(username) ALL=(root) NOPASSWD: \(PrivilegedPaths.helper) disable + public var contents: String { + """ + \(username) ALL=(root) NOPASSWD: \(PrivilegedPaths.helper) enable + \(username) ALL=(root) NOPASSWD: \(PrivilegedPaths.helper) disable - """ - } + """ + } - private static func isSafe(_ scalar: Unicode.Scalar) -> Bool { - switch scalar.value { - case 45, 46, 48...57, 65...90, 95, 97...122: - true - default: - false - } + private static func isSafe(_ scalar: Unicode.Scalar) -> Bool { + switch scalar.value { + case 45, 46, 48...57, 65...90, 95, 97...122: + true + default: + false } + } } diff --git a/Sources/ClamshellCore/Process/FoundationProcessRunner.swift b/Sources/ClamshellCore/Process/FoundationProcessRunner.swift index 2b849e7..4763fa9 100644 --- a/Sources/ClamshellCore/Process/FoundationProcessRunner.swift +++ b/Sources/ClamshellCore/Process/FoundationProcessRunner.swift @@ -2,86 +2,86 @@ import Dispatch import Foundation public struct FoundationProcessRunner: ProcessRunning { - public init() {} + public init() {} - public func run(_ executable: String, arguments: [String]) throws -> ProcessResult { - let process = Process() - let standardOutput = Pipe() - let standardError = Pipe() + public func run(_ executable: String, arguments: [String]) throws -> ProcessResult { + let process = Process() + let standardOutput = Pipe() + let standardError = Pipe() - process.executableURL = URL(fileURLWithPath: executable) - process.arguments = arguments - process.standardOutput = standardOutput - process.standardError = standardError + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + process.standardOutput = standardOutput + process.standardError = standardError - try process.run() + try process.run() - let collector = ProcessOutputCollector( - standardOutput: standardOutput.fileHandleForReading, - standardError: standardError.fileHandleForReading - ) - collector.start() - process.waitUntilExit() + let collector = ProcessOutputCollector( + standardOutput: standardOutput.fileHandleForReading, + standardError: standardError.fileHandleForReading + ) + collector.start() + process.waitUntilExit() - let output = collector.waitForOutput() - guard let outputString = String(data: output.standardOutput, encoding: .utf8) else { - throw ClamshellError.invalidProcessOutput( - executable: executable, - stream: .standardOutput - ) - } - guard let errorString = String(data: output.standardError, encoding: .utf8) else { - throw ClamshellError.invalidProcessOutput( - executable: executable, - stream: .standardError - ) - } - - return ProcessResult( - standardOutput: outputString, - standardError: errorString, - terminationStatus: process.terminationStatus - ) + let output = collector.waitForOutput() + guard let outputString = String(data: output.standardOutput, encoding: .utf8) else { + throw ClamshellError.invalidProcessOutput( + executable: executable, + stream: .standardOutput + ) + } + guard let errorString = String(data: output.standardError, encoding: .utf8) else { + throw ClamshellError.invalidProcessOutput( + executable: executable, + stream: .standardError + ) } + + return ProcessResult( + standardOutput: outputString, + standardError: errorString, + terminationStatus: process.terminationStatus + ) + } } private final class ProcessOutputCollector: @unchecked Sendable { - private let standardOutput: FileHandle - private let standardError: FileHandle - private let group = DispatchGroup() - private let lock = NSLock() - private var outputData = Data() - private var errorData = Data() + private let standardOutput: FileHandle + private let standardError: FileHandle + private let group = DispatchGroup() + private let lock = NSLock() + private var outputData = Data() + private var errorData = Data() - init(standardOutput: FileHandle, standardError: FileHandle) { - self.standardOutput = standardOutput - self.standardError = standardError - } + init(standardOutput: FileHandle, standardError: FileHandle) { + self.standardOutput = standardOutput + self.standardError = standardError + } - func start() { - group.enter() - DispatchQueue.global(qos: .utility).async { - let data = self.standardOutput.readDataToEndOfFile() - self.lock.withLock { - self.outputData = data - } - self.group.leave() - } + func start() { + group.enter() + DispatchQueue.global(qos: .utility).async { + let data = self.standardOutput.readDataToEndOfFile() + self.lock.withLock { + self.outputData = data + } + self.group.leave() + } - group.enter() - DispatchQueue.global(qos: .utility).async { - let data = self.standardError.readDataToEndOfFile() - self.lock.withLock { - self.errorData = data - } - self.group.leave() - } + group.enter() + DispatchQueue.global(qos: .utility).async { + let data = self.standardError.readDataToEndOfFile() + self.lock.withLock { + self.errorData = data + } + self.group.leave() } + } - func waitForOutput() -> (standardOutput: Data, standardError: Data) { - group.wait() - return lock.withLock { - (outputData, errorData) - } + func waitForOutput() -> (standardOutput: Data, standardError: Data) { + group.wait() + return lock.withLock { + (outputData, errorData) } + } } diff --git a/Sources/ClamshellCore/Process/ProcessOutputStream.swift b/Sources/ClamshellCore/Process/ProcessOutputStream.swift index 71890fb..d2113e3 100644 --- a/Sources/ClamshellCore/Process/ProcessOutputStream.swift +++ b/Sources/ClamshellCore/Process/ProcessOutputStream.swift @@ -1,4 +1,4 @@ public enum ProcessOutputStream: String, Sendable { - case standardOutput - case standardError + case standardOutput + case standardError } diff --git a/Sources/ClamshellCore/Process/ProcessResult.swift b/Sources/ClamshellCore/Process/ProcessResult.swift index 6bb2522..2512239 100644 --- a/Sources/ClamshellCore/Process/ProcessResult.swift +++ b/Sources/ClamshellCore/Process/ProcessResult.swift @@ -1,15 +1,15 @@ public struct ProcessResult: Sendable, Equatable { - public let standardOutput: String - public let standardError: String - public let terminationStatus: Int32 + public let standardOutput: String + public let standardError: String + public let terminationStatus: Int32 - public init( - standardOutput: String, - standardError: String, - terminationStatus: Int32 - ) { - self.standardOutput = standardOutput - self.standardError = standardError - self.terminationStatus = terminationStatus - } + public init( + standardOutput: String, + standardError: String, + terminationStatus: Int32 + ) { + self.standardOutput = standardOutput + self.standardError = standardError + self.terminationStatus = terminationStatus + } } diff --git a/Sources/ClamshellCore/Process/ProcessRunning.swift b/Sources/ClamshellCore/Process/ProcessRunning.swift index 4fd6acc..91ca056 100644 --- a/Sources/ClamshellCore/Process/ProcessRunning.swift +++ b/Sources/ClamshellCore/Process/ProcessRunning.swift @@ -1,3 +1,3 @@ public protocol ProcessRunning: Sendable { - func run(_ executable: String, arguments: [String]) throws -> ProcessResult + func run(_ executable: String, arguments: [String]) throws -> ProcessResult } diff --git a/Sources/ClamshellCore/State/ClamshellService.swift b/Sources/ClamshellCore/State/ClamshellService.swift index bc7dbf0..000207b 100644 --- a/Sources/ClamshellCore/State/ClamshellService.swift +++ b/Sources/ClamshellCore/State/ClamshellService.swift @@ -1,51 +1,51 @@ public struct ClamshellService: Sendable { - private let stateReader: any PowerStateReading - private let stateWriter: any PowerStateWriting + private let stateReader: any PowerStateReading + private let stateWriter: any PowerStateWriting - public init( - stateReader: any PowerStateReading, - stateWriter: any PowerStateWriting - ) { - self.stateReader = stateReader - self.stateWriter = stateWriter - } - - public func set(_ requested: ClamshellState) throws -> TransitionResult { - let current = try stateReader.currentState() - return try set(requested, from: current) - } + public init( + stateReader: any PowerStateReading, + stateWriter: any PowerStateWriting + ) { + self.stateReader = stateReader + self.stateWriter = stateWriter + } - public func toggle() throws -> TransitionResult { - let current = try stateReader.currentState() - let requested: ClamshellState = current == .enabled ? .disabled : .enabled - return try set(requested, from: current) - } + public func set(_ requested: ClamshellState) throws -> TransitionResult { + let current = try stateReader.currentState() + return try set(requested, from: current) + } - private func set( - _ requested: ClamshellState, - from previous: ClamshellState - ) throws -> TransitionResult { - guard previous != requested else { - return TransitionResult( - previous: previous, - current: previous, - didChange: false - ) - } + public func toggle() throws -> TransitionResult { + let current = try stateReader.currentState() + let requested: ClamshellState = current == .enabled ? .disabled : .enabled + return try set(requested, from: current) + } - try stateWriter.setState(requested) - let current = try stateReader.currentState() - guard current == requested else { - throw ClamshellError.stateVerificationFailed( - expected: requested, - actual: current - ) - } + private func set( + _ requested: ClamshellState, + from previous: ClamshellState + ) throws -> TransitionResult { + guard previous != requested else { + return TransitionResult( + previous: previous, + current: previous, + didChange: false + ) + } - return TransitionResult( - previous: previous, - current: current, - didChange: true - ) + try stateWriter.setState(requested) + let current = try stateReader.currentState() + guard current == requested else { + throw ClamshellError.stateVerificationFailed( + expected: requested, + actual: current + ) } + + return TransitionResult( + previous: previous, + current: current, + didChange: true + ) + } } diff --git a/Sources/ClamshellCore/State/ClamshellState.swift b/Sources/ClamshellCore/State/ClamshellState.swift index 3e451f6..6f467a6 100644 --- a/Sources/ClamshellCore/State/ClamshellState.swift +++ b/Sources/ClamshellCore/State/ClamshellState.swift @@ -1,4 +1,4 @@ public enum ClamshellState: String, Sendable { - case enabled - case disabled + case enabled + case disabled } diff --git a/Sources/ClamshellCore/State/PowerStateReading.swift b/Sources/ClamshellCore/State/PowerStateReading.swift index b51d193..4e8d1d8 100644 --- a/Sources/ClamshellCore/State/PowerStateReading.swift +++ b/Sources/ClamshellCore/State/PowerStateReading.swift @@ -1,3 +1,3 @@ public protocol PowerStateReading: Sendable { - func currentState() throws -> ClamshellState + func currentState() throws -> ClamshellState } diff --git a/Sources/ClamshellCore/State/PowerStateWriting.swift b/Sources/ClamshellCore/State/PowerStateWriting.swift index 26f8d7a..ddefa9f 100644 --- a/Sources/ClamshellCore/State/PowerStateWriting.swift +++ b/Sources/ClamshellCore/State/PowerStateWriting.swift @@ -1,3 +1,3 @@ public protocol PowerStateWriting: Sendable { - func setState(_ state: ClamshellState) throws + func setState(_ state: ClamshellState) throws } diff --git a/Sources/ClamshellCore/State/TransitionResult.swift b/Sources/ClamshellCore/State/TransitionResult.swift index 7a2b34d..74e0cbe 100644 --- a/Sources/ClamshellCore/State/TransitionResult.swift +++ b/Sources/ClamshellCore/State/TransitionResult.swift @@ -1,11 +1,11 @@ public struct TransitionResult: Sendable, Equatable { - public let previous: ClamshellState - public let current: ClamshellState - public let didChange: Bool + public let previous: ClamshellState + public let current: ClamshellState + public let didChange: Bool - public init(previous: ClamshellState, current: ClamshellState, didChange: Bool) { - self.previous = previous - self.current = current - self.didChange = didChange - } + public init(previous: ClamshellState, current: ClamshellState, didChange: Bool) { + self.previous = previous + self.current = current + self.didChange = didChange + } } diff --git a/Sources/ClamshellHelper/ClamshellHelper.swift b/Sources/ClamshellHelper/ClamshellHelper.swift index ef5db1c..47884b1 100644 --- a/Sources/ClamshellHelper/ClamshellHelper.swift +++ b/Sources/ClamshellHelper/ClamshellHelper.swift @@ -4,30 +4,30 @@ import Foundation @main enum ClamshellHelper { - static func main() { - guard geteuid() == 0 else { - fail("Administrator privileges are required.", exitCode: EX_NOPERM) - } - - do { - let mutation = try PowerMutation( - rawArguments: Array(CommandLine.arguments.dropFirst()) - ) - let powerSettings = PowerSettingsClient(runner: FoundationProcessRunner()) - let service = ClamshellService( - stateReader: powerSettings, - stateWriter: powerSettings - ) - _ = try service.set(mutation.state) - } catch ClamshellError.invalidHelperArguments { - fail("Usage: clamshellctl-helper ", exitCode: EX_USAGE) - } catch { - fail("Unable to update battery clamshell mode.", exitCode: EX_SOFTWARE) - } + static func main() { + guard geteuid() == 0 else { + fail("Administrator privileges are required.", exitCode: EX_NOPERM) } - private static func fail(_ message: String, exitCode: Int32) -> Never { - FileHandle.standardError.write(Data("\(message)\n".utf8)) - exit(exitCode) + do { + let mutation = try PowerMutation( + rawArguments: Array(CommandLine.arguments.dropFirst()) + ) + let powerSettings = PowerSettingsClient(runner: FoundationProcessRunner()) + let service = ClamshellService( + stateReader: powerSettings, + stateWriter: powerSettings + ) + _ = try service.set(mutation.state) + } catch ClamshellError.invalidHelperArguments { + fail("Usage: clamshellctl-helper ", exitCode: EX_USAGE) + } catch { + fail("Unable to update battery clamshell mode.", exitCode: EX_SOFTWARE) } + } + + private static func fail(_ message: String, exitCode: Int32) -> Never { + FileHandle.standardError.write(Data("\(message)\n".utf8)) + exit(exitCode) + } } diff --git a/Tests/ClamshellCLITests/Commands/SetupCommandTests.swift b/Tests/ClamshellCLITests/Commands/SetupCommandTests.swift index 8c7d03d..e9b6b83 100644 --- a/Tests/ClamshellCLITests/Commands/SetupCommandTests.swift +++ b/Tests/ClamshellCLITests/Commands/SetupCommandTests.swift @@ -6,29 +6,29 @@ import Testing @Suite("Setup commands") struct SetupCommandTests { - @Test("setup requires an explicit administrator invocation") - func setupRequiresRoot() throws { - var command = try ClamshellCommand.parseAsRoot(["setup"]) + @Test("setup requires an explicit administrator invocation") + func setupRequiresRoot() throws { + var command = try ClamshellCommand.parseAsRoot(["setup"]) - #expect( - throws: ClamshellError.administratorPrivilegesRequired( - command: PrivilegedHelperClient.setupCommand - ) - ) { - try command.run() - } + #expect( + throws: ClamshellError.administratorPrivilegesRequired( + command: PrivilegedHelperClient.setupCommand + ) + ) { + try command.run() } + } - @Test("uninstall requires an explicit administrator invocation") - func uninstallRequiresRoot() throws { - var command = try ClamshellCommand.parseAsRoot(["uninstall"]) + @Test("uninstall requires an explicit administrator invocation") + func uninstallRequiresRoot() throws { + var command = try ClamshellCommand.parseAsRoot(["uninstall"]) - #expect( - throws: ClamshellError.administratorPrivilegesRequired( - command: PrivilegedHelperClient.uninstallCommand - ) - ) { - try command.run() - } + #expect( + throws: ClamshellError.administratorPrivilegesRequired( + command: PrivilegedHelperClient.uninstallCommand + ) + ) { + try command.run() } + } } diff --git a/Tests/ClamshellCoreTests/BuildVersionTests.swift b/Tests/ClamshellCoreTests/BuildVersionTests.swift index 087fe90..2138b2f 100644 --- a/Tests/ClamshellCoreTests/BuildVersionTests.swift +++ b/Tests/ClamshellCoreTests/BuildVersionTests.swift @@ -4,8 +4,8 @@ import Testing @Suite("Build version") struct BuildVersionTests { - @Test("starts at the planned initial version") - func initialVersion() { - #expect(BuildVersion.current == "0.1.0") - } + @Test("starts at the planned initial version") + func initialVersion() { + #expect(BuildVersion.current == "0.1.0") + } } diff --git a/Tests/ClamshellCoreTests/Power/PowerMutationTests.swift b/Tests/ClamshellCoreTests/Power/PowerMutationTests.swift index 9a1d36b..8546aee 100644 --- a/Tests/ClamshellCoreTests/Power/PowerMutationTests.swift +++ b/Tests/ClamshellCoreTests/Power/PowerMutationTests.swift @@ -5,108 +5,108 @@ import Testing @Suite("Privileged power mutation") struct PowerMutationTests { - @Test( - "accepts one exact helper action", - arguments: [ - (["enable"], ClamshellState.enabled), - (["disable"], ClamshellState.disabled), - ] - ) - func acceptedArguments(arguments: [String], expectedState: ClamshellState) throws { - #expect(try PowerMutation(rawArguments: arguments).state == expectedState) - } + @Test( + "accepts one exact helper action", + arguments: [ + (["enable"], ClamshellState.enabled), + (["disable"], ClamshellState.disabled), + ] + ) + func acceptedArguments(arguments: [String], expectedState: ClamshellState) throws { + #expect(try PowerMutation(rawArguments: arguments).state == expectedState) + } - @Test("rejects every other helper argument shape") - func rejectedArguments() { - let invalidArguments = [ - [], - ["enable", "disable"], - ["--enable"], - ["Enable"], - ["1"], - ["enable", "extra"], - ] + @Test("rejects every other helper argument shape") + func rejectedArguments() { + let invalidArguments = [ + [], + ["enable", "disable"], + ["--enable"], + ["Enable"], + ["1"], + ["enable", "extra"], + ] - for arguments in invalidArguments { - #expect(throws: ClamshellError.invalidHelperArguments) { - try PowerMutation(rawArguments: arguments) - } - } + for arguments in invalidArguments { + #expect(throws: ClamshellError.invalidHelperArguments) { + try PowerMutation(rawArguments: arguments) + } } + } - @Test( - "maps states to battery-only pmset arguments", - arguments: [ - (ClamshellState.enabled, "1"), - (.disabled, "0"), - ] + @Test( + "maps states to battery-only pmset arguments", + arguments: [ + (ClamshellState.enabled, "1"), + (.disabled, "0"), + ] + ) + func pmsetArguments(state: ClamshellState, value: String) throws { + let runner = MutationRecordingRunner( + result: ProcessResult( + standardOutput: "", + standardError: "", + terminationStatus: 0 + ) ) - func pmsetArguments(state: ClamshellState, value: String) throws { - let runner = MutationRecordingRunner( - result: ProcessResult( - standardOutput: "", - standardError: "", - terminationStatus: 0 - ) - ) - try PowerSettingsClient(runner: runner).setState(state) - - #expect( - runner.invocations == [ - MutationInvocation( - executable: "/usr/bin/pmset", - arguments: ["-b", "disablesleep", value] - ) - ]) - } + try PowerSettingsClient(runner: runner).setState(state) - @Test("preserves a failed mutation exit status and stderr") - func mutationFailure() { - let runner = MutationRecordingRunner( - result: ProcessResult( - standardOutput: "", - standardError: "permission denied", - terminationStatus: 77 - ) + #expect( + runner.invocations == [ + MutationInvocation( + executable: "/usr/bin/pmset", + arguments: ["-b", "disablesleep", value] ) + ]) + } - #expect( - throws: ClamshellError.processFailed( - executable: "/usr/bin/pmset", - terminationStatus: 77, - standardError: "permission denied" - ) - ) { - try PowerSettingsClient(runner: runner).setState(.enabled) - } + @Test("preserves a failed mutation exit status and stderr") + func mutationFailure() { + let runner = MutationRecordingRunner( + result: ProcessResult( + standardOutput: "", + standardError: "permission denied", + terminationStatus: 77 + ) + ) + + #expect( + throws: ClamshellError.processFailed( + executable: "/usr/bin/pmset", + terminationStatus: 77, + standardError: "permission denied" + ) + ) { + try PowerSettingsClient(runner: runner).setState(.enabled) } + } } private struct MutationInvocation: Equatable { - let executable: String - let arguments: [String] + let executable: String + let arguments: [String] } private final class MutationRecordingRunner: ProcessRunning, @unchecked Sendable { - private let lock = NSLock() - private let result: ProcessResult - private var recordedInvocations: [MutationInvocation] = [] + private let lock = NSLock() + private let result: ProcessResult + private var recordedInvocations: [MutationInvocation] = [] - init(result: ProcessResult) { - self.result = result - } + init(result: ProcessResult) { + self.result = result + } - var invocations: [MutationInvocation] { - lock.withLock { recordedInvocations } - } + var invocations: [MutationInvocation] { + lock.withLock { recordedInvocations } + } - func run(_ executable: String, arguments: [String]) throws -> ProcessResult { - lock.withLock { - recordedInvocations.append( - MutationInvocation(executable: executable, arguments: arguments) - ) - } - return result + func run(_ executable: String, arguments: [String]) throws -> ProcessResult { + lock.withLock { + recordedInvocations.append( + MutationInvocation(executable: executable, arguments: arguments) + ) } + return result + } } diff --git a/Tests/ClamshellCoreTests/Power/PowerSettingsClientTests.swift b/Tests/ClamshellCoreTests/Power/PowerSettingsClientTests.swift index ff4280b..d993430 100644 --- a/Tests/ClamshellCoreTests/Power/PowerSettingsClientTests.swift +++ b/Tests/ClamshellCoreTests/Power/PowerSettingsClientTests.swift @@ -5,81 +5,81 @@ import Testing @Suite("Power settings client") struct PowerSettingsClientTests { - @Test("reads the battery state with pmset custom settings") - func currentState() throws { - let runner = RecordingProcessRunner( - result: ProcessResult( - standardOutput: """ - Battery Power: - disablesleep 1 - AC Power: - disablesleep 0 - """, - standardError: "", - terminationStatus: 0 - ) - ) - - let state = try PowerSettingsClient(runner: runner).currentState() + @Test("reads the battery state with pmset custom settings") + func currentState() throws { + let runner = RecordingProcessRunner( + result: ProcessResult( + standardOutput: """ + Battery Power: + disablesleep 1 + AC Power: + disablesleep 0 + """, + standardError: "", + terminationStatus: 0 + ) + ) - #expect(state == .enabled) - #expect( - runner.invocations == [ - ProcessInvocation( - executable: "/usr/bin/pmset", - arguments: ["-g", "custom"] - ) - ]) - } + let state = try PowerSettingsClient(runner: runner).currentState() - @Test("preserves a failed pmset exit status and stderr") - func processFailure() { - let runner = RecordingProcessRunner( - result: ProcessResult( - standardOutput: "", - standardError: "pmset failed", - terminationStatus: 64 - ) + #expect(state == .enabled) + #expect( + runner.invocations == [ + ProcessInvocation( + executable: "/usr/bin/pmset", + arguments: ["-g", "custom"] ) + ]) + } - #expect( - throws: ClamshellError.processFailed( - executable: "/usr/bin/pmset", - terminationStatus: 64, - standardError: "pmset failed" - ) - ) { - try PowerSettingsClient(runner: runner).currentState() - } + @Test("preserves a failed pmset exit status and stderr") + func processFailure() { + let runner = RecordingProcessRunner( + result: ProcessResult( + standardOutput: "", + standardError: "pmset failed", + terminationStatus: 64 + ) + ) + + #expect( + throws: ClamshellError.processFailed( + executable: "/usr/bin/pmset", + terminationStatus: 64, + standardError: "pmset failed" + ) + ) { + try PowerSettingsClient(runner: runner).currentState() } + } } private struct ProcessInvocation: Equatable { - let executable: String - let arguments: [String] + let executable: String + let arguments: [String] } private final class RecordingProcessRunner: ProcessRunning, @unchecked Sendable { - private let lock = NSLock() - private let result: ProcessResult - private var recordedInvocations: [ProcessInvocation] = [] + private let lock = NSLock() + private let result: ProcessResult + private var recordedInvocations: [ProcessInvocation] = [] - init(result: ProcessResult) { - self.result = result - } + init(result: ProcessResult) { + self.result = result + } - var invocations: [ProcessInvocation] { - lock.withLock { - recordedInvocations - } + var invocations: [ProcessInvocation] { + lock.withLock { + recordedInvocations } + } - func run(_ executable: String, arguments: [String]) throws -> ProcessResult { - lock.withLock { - recordedInvocations.append( - ProcessInvocation(executable: executable, arguments: arguments) - ) - } - return result + func run(_ executable: String, arguments: [String]) throws -> ProcessResult { + lock.withLock { + recordedInvocations.append( + ProcessInvocation(executable: executable, arguments: arguments) + ) } + return result + } } diff --git a/Tests/ClamshellCoreTests/Power/PowerSettingsParserTests.swift b/Tests/ClamshellCoreTests/Power/PowerSettingsParserTests.swift index 01ae636..904f4d3 100644 --- a/Tests/ClamshellCoreTests/Power/PowerSettingsParserTests.swift +++ b/Tests/ClamshellCoreTests/Power/PowerSettingsParserTests.swift @@ -4,65 +4,65 @@ import Testing @Suite("pmset parser") struct PowerSettingsParserTests { - @Test("reads enabled from Battery Power") - func enabled() throws { - let output = """ - Battery Power: - sleep 1 - disablesleep 1 - AC Power: - sleep 1 - disablesleep 0 - """ + @Test("reads enabled from Battery Power") + func enabled() throws { + let output = """ + Battery Power: + sleep 1 + disablesleep 1 + AC Power: + sleep 1 + disablesleep 0 + """ - #expect(try PowerSettingsParser().batteryState(from: output) == .enabled) - } + #expect(try PowerSettingsParser().batteryState(from: output) == .enabled) + } - @Test("reads disabled from Battery Power") - func disabled() throws { - let output = """ - Battery Power: - sleep 1 - disablesleep 0 - AC Power: - disablesleep 1 - """ + @Test("reads disabled from Battery Power") + func disabled() throws { + let output = """ + Battery Power: + sleep 1 + disablesleep 0 + AC Power: + disablesleep 1 + """ - #expect(try PowerSettingsParser().batteryState(from: output) == .disabled) - } + #expect(try PowerSettingsParser().batteryState(from: output) == .disabled) + } - @Test("treats an absent battery disablesleep key as disabled") - func absentMeansDisabled() throws { - let output = """ - Battery Power: - sleep 1 - AC Power: - disablesleep 1 - """ + @Test("treats an absent battery disablesleep key as disabled") + func absentMeansDisabled() throws { + let output = """ + Battery Power: + sleep 1 + AC Power: + disablesleep 1 + """ - #expect(try PowerSettingsParser().batteryState(from: output) == .disabled) - } + #expect(try PowerSettingsParser().batteryState(from: output) == .disabled) + } - @Test("rejects output without a Battery Power section") - func missingBatterySection() { - #expect(throws: ClamshellError.self) { - try PowerSettingsParser().batteryState( - from: "AC Power:\n disablesleep 1" - ) - } + @Test("rejects output without a Battery Power section") + func missingBatterySection() { + #expect(throws: ClamshellError.self) { + try PowerSettingsParser().batteryState( + from: "AC Power:\n disablesleep 1" + ) } + } - @Test("rejects an unexpected battery disablesleep value") - func unexpectedValue() { - let output = """ - Battery Power: - disablesleep 2 - AC Power: - disablesleep 0 - """ + @Test("rejects an unexpected battery disablesleep value") + func unexpectedValue() { + let output = """ + Battery Power: + disablesleep 2 + AC Power: + disablesleep 0 + """ - #expect(throws: ClamshellError.self) { - try PowerSettingsParser().batteryState(from: output) - } + #expect(throws: ClamshellError.self) { + try PowerSettingsParser().batteryState(from: output) } + } } diff --git a/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationTests.swift b/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationTests.swift index 891ea79..680a46b 100644 --- a/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationTests.swift +++ b/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationTests.swift @@ -5,260 +5,260 @@ import Testing @Suite("Privileged installation") struct PrivilegedInstallationTests { - @Test("installs the helper and validated policy in order") - func install() throws { - let log = InstallationOperationLog() - let source = "/tmp/build/clamshellctl-helper" - let fileSystem = RecordingInstallationFileSystem( - files: [source: "helper payload"], - log: log - ) - let runner = InstallationRecordingRunner( - result: ProcessResult( - standardOutput: "", - standardError: "", - terminationStatus: 0 - ), - log: log - ) - let installation = PrivilegedInstallation( - fileSystem: fileSystem, - runner: runner, - effectiveUserID: 0, - environment: ["SUDO_USER": "liam"], - executablePath: "/tmp/build/clamshellctl", - temporarySuffix: "test" - ) + @Test("installs the helper and validated policy in order") + func install() throws { + let log = InstallationOperationLog() + let source = "/tmp/build/clamshellctl-helper" + let fileSystem = RecordingInstallationFileSystem( + files: [source: "helper payload"], + log: log + ) + let runner = InstallationRecordingRunner( + result: ProcessResult( + standardOutput: "", + standardError: "", + terminationStatus: 0 + ), + log: log + ) + let installation = PrivilegedInstallation( + fileSystem: fileSystem, + runner: runner, + effectiveUserID: 0, + environment: ["SUDO_USER": "liam"], + executablePath: "/tmp/build/clamshellctl", + temporarySuffix: "test" + ) - let result = try installation.install() + let result = try installation.install() - let helperTemporary = "\(PrivilegedPaths.helper).installing.test" - let policyTemporary = "\(PrivilegedPaths.sudoersPolicy).installing.test" - #expect( - result - == InstallationResult( - helperPath: PrivilegedPaths.helper, - sudoersPolicyPath: PrivilegedPaths.sudoersPolicy - ) - ) - #expect( - log.operations == [ - .isRegularFile(source), - .isRegularFile(PrivilegedPaths.helper), - .copy(source: source, destination: helperTemporary), - .setOwner(path: helperTemporary, userID: 0, groupID: 0), - .setPermissions(path: helperTemporary, permissions: 0o755), - .replace(replacement: helperTemporary, destination: PrivilegedPaths.helper), - .write( - contents: try SudoersPolicy(username: "liam").contents, - path: policyTemporary - ), - .setOwner(path: policyTemporary, userID: 0, groupID: 0), - .setPermissions(path: policyTemporary, permissions: 0o440), - .run( - executable: "/usr/sbin/visudo", - arguments: ["-cf", policyTemporary] - ), - .replace( - replacement: policyTemporary, - destination: PrivilegedPaths.sudoersPolicy - ), - .isRegularFile(PrivilegedPaths.helper), - .contentsEqual( - firstPath: source, - secondPath: PrivilegedPaths.helper - ), - .attributes(PrivilegedPaths.helper), - .isRegularFile(PrivilegedPaths.sudoersPolicy), - .readText(PrivilegedPaths.sudoersPolicy), - .attributes(PrivilegedPaths.sudoersPolicy), - .run( - executable: "/usr/sbin/visudo", - arguments: ["-cf", PrivilegedPaths.sudoersPolicy] - ), - ] + let helperTemporary = "\(PrivilegedPaths.helper).installing.test" + let policyTemporary = "\(PrivilegedPaths.sudoersPolicy).installing.test" + #expect( + result + == InstallationResult( + helperPath: PrivilegedPaths.helper, + sudoersPolicyPath: PrivilegedPaths.sudoersPolicy ) - } + ) + #expect( + log.operations == [ + .isRegularFile(source), + .isRegularFile(PrivilegedPaths.helper), + .copy(source: source, destination: helperTemporary), + .setOwner(path: helperTemporary, userID: 0, groupID: 0), + .setPermissions(path: helperTemporary, permissions: 0o755), + .replace(replacement: helperTemporary, destination: PrivilegedPaths.helper), + .write( + contents: try SudoersPolicy(username: "liam").contents, + path: policyTemporary + ), + .setOwner(path: policyTemporary, userID: 0, groupID: 0), + .setPermissions(path: policyTemporary, permissions: 0o440), + .run( + executable: "/usr/sbin/visudo", + arguments: ["-cf", policyTemporary] + ), + .replace( + replacement: policyTemporary, + destination: PrivilegedPaths.sudoersPolicy + ), + .isRegularFile(PrivilegedPaths.helper), + .contentsEqual( + firstPath: source, + secondPath: PrivilegedPaths.helper + ), + .attributes(PrivilegedPaths.helper), + .isRegularFile(PrivilegedPaths.sudoersPolicy), + .readText(PrivilegedPaths.sudoersPolicy), + .attributes(PrivilegedPaths.sudoersPolicy), + .run( + executable: "/usr/sbin/visudo", + arguments: ["-cf", PrivilegedPaths.sudoersPolicy] + ), + ] + ) + } - @Test("finds a Homebrew libexec payload beside the installation prefix") - func homebrewPayload() throws { - let log = InstallationOperationLog() - let source = "/opt/homebrew/Cellar/clamshellctl/0.1.0/libexec/clamshellctl-helper" - let fileSystem = RecordingInstallationFileSystem( - files: [source: "helper payload"], - log: log - ) - let runner = InstallationRecordingRunner(result: .success, log: log) - let installation = PrivilegedInstallation( - fileSystem: fileSystem, - runner: runner, - effectiveUserID: 0, - environment: ["SUDO_USER": "liam"], - executablePath: "/opt/homebrew/Cellar/clamshellctl/0.1.0/bin/clamshellctl", - temporarySuffix: "test" - ) + @Test("finds a Homebrew libexec payload beside the installation prefix") + func homebrewPayload() throws { + let log = InstallationOperationLog() + let source = "/opt/homebrew/Cellar/clamshellctl/0.1.0/libexec/clamshellctl-helper" + let fileSystem = RecordingInstallationFileSystem( + files: [source: "helper payload"], + log: log + ) + let runner = InstallationRecordingRunner(result: .success, log: log) + let installation = PrivilegedInstallation( + fileSystem: fileSystem, + runner: runner, + effectiveUserID: 0, + environment: ["SUDO_USER": "liam"], + executablePath: "/opt/homebrew/Cellar/clamshellctl/0.1.0/bin/clamshellctl", + temporarySuffix: "test" + ) - _ = try installation.install() + _ = try installation.install() - #expect( - Array(log.operations.prefix(4)) == [ - .isRegularFile( - "/opt/homebrew/Cellar/clamshellctl/0.1.0/bin/clamshellctl-helper" - ), - .isRegularFile(source), - .isRegularFile(PrivilegedPaths.helper), - .copy( - source: source, - destination: "\(PrivilegedPaths.helper).installing.test" - ), - ] - ) - } + #expect( + Array(log.operations.prefix(4)) == [ + .isRegularFile( + "/opt/homebrew/Cellar/clamshellctl/0.1.0/bin/clamshellctl-helper" + ), + .isRegularFile(source), + .isRegularFile(PrivilegedPaths.helper), + .copy( + source: source, + destination: "\(PrivilegedPaths.helper).installing.test" + ), + ] + ) + } - @Test("recognises an already verified installation") - func alreadyInstalled() throws { - let log = InstallationOperationLog() - let source = "/tmp/build/clamshellctl-helper" - let fileSystem = RecordingInstallationFileSystem( - files: [source: "helper payload"], - log: log - ) - let runner = InstallationRecordingRunner(result: .success, log: log) - let installation = PrivilegedInstallation( - fileSystem: fileSystem, - runner: runner, - effectiveUserID: 0, - environment: ["SUDO_USER": "liam"], - executablePath: "/tmp/build/clamshellctl", - temporarySuffix: "test" - ) + @Test("recognises an already verified installation") + func alreadyInstalled() throws { + let log = InstallationOperationLog() + let source = "/tmp/build/clamshellctl-helper" + let fileSystem = RecordingInstallationFileSystem( + files: [source: "helper payload"], + log: log + ) + let runner = InstallationRecordingRunner(result: .success, log: log) + let installation = PrivilegedInstallation( + fileSystem: fileSystem, + runner: runner, + effectiveUserID: 0, + environment: ["SUDO_USER": "liam"], + executablePath: "/tmp/build/clamshellctl", + temporarySuffix: "test" + ) - let first = try installation.install() - let operationCount = log.operations.count - let second = try installation.install() - let repeatedOperations = Array(log.operations.dropFirst(operationCount)) + let first = try installation.install() + let operationCount = log.operations.count + let second = try installation.install() + let repeatedOperations = Array(log.operations.dropFirst(operationCount)) - #expect(first.didChange) - #expect(!second.didChange) - #expect( - !repeatedOperations.contains { operation in - switch operation { - case .copy, .write, .replace: - true - default: - false - } - }) - } + #expect(first.didChange) + #expect(!second.didChange) + #expect( + !repeatedOperations.contains { operation in + switch operation { + case .copy, .write, .replace: + true + default: + false + } + }) + } - @Test("rejects setup before file access when not running as root") - func rootRequired() { - let log = InstallationOperationLog() - let installation = PrivilegedInstallation( - fileSystem: RecordingInstallationFileSystem(files: [:], log: log), - runner: InstallationRecordingRunner(result: .success, log: log), - effectiveUserID: 501, - environment: ["SUDO_USER": "liam"], - executablePath: "/tmp/build/clamshellctl", - temporarySuffix: "test" - ) + @Test("rejects setup before file access when not running as root") + func rootRequired() { + let log = InstallationOperationLog() + let installation = PrivilegedInstallation( + fileSystem: RecordingInstallationFileSystem(files: [:], log: log), + runner: InstallationRecordingRunner(result: .success, log: log), + effectiveUserID: 501, + environment: ["SUDO_USER": "liam"], + executablePath: "/tmp/build/clamshellctl", + temporarySuffix: "test" + ) - #expect( - throws: ClamshellError.administratorPrivilegesRequired( - command: PrivilegedHelperClient.setupCommand - ) - ) { - try installation.install() - } - #expect(log.operations.isEmpty) + #expect( + throws: ClamshellError.administratorPrivilegesRequired( + command: PrivilegedHelperClient.setupCommand + ) + ) { + try installation.install() } + #expect(log.operations.isEmpty) + } - @Test("requires the original sudo user") - func originalUserRequired() { - let log = InstallationOperationLog() - let installation = PrivilegedInstallation( - fileSystem: RecordingInstallationFileSystem(files: [:], log: log), - runner: InstallationRecordingRunner(result: .success, log: log), - effectiveUserID: 0, - environment: [:], - executablePath: "/tmp/build/clamshellctl", - temporarySuffix: "test" - ) + @Test("requires the original sudo user") + func originalUserRequired() { + let log = InstallationOperationLog() + let installation = PrivilegedInstallation( + fileSystem: RecordingInstallationFileSystem(files: [:], log: log), + runner: InstallationRecordingRunner(result: .success, log: log), + effectiveUserID: 0, + environment: [:], + executablePath: "/tmp/build/clamshellctl", + temporarySuffix: "test" + ) - #expect(throws: ClamshellError.originalUserUnavailable) { - try installation.install() - } - #expect(log.operations.isEmpty) + #expect(throws: ClamshellError.originalUserUnavailable) { + try installation.install() } + #expect(log.operations.isEmpty) + } - @Test("keeps the existing policy when visudo rejects the replacement") - func failedValidation() { - let log = InstallationOperationLog() - let source = "/tmp/build/clamshellctl-helper" - let fileSystem = RecordingInstallationFileSystem( - files: [ - source: "helper payload", - PrivilegedPaths.sudoersPolicy: "existing policy", - ], - log: log - ) - let runner = InstallationRecordingRunner( - result: ProcessResult( - standardOutput: "", - standardError: "syntax error", - terminationStatus: 1 - ), - log: log - ) - let installation = PrivilegedInstallation( - fileSystem: fileSystem, - runner: runner, - effectiveUserID: 0, - environment: ["SUDO_USER": "liam"], - executablePath: "/tmp/build/clamshellctl", - temporarySuffix: "test" - ) + @Test("keeps the existing policy when visudo rejects the replacement") + func failedValidation() { + let log = InstallationOperationLog() + let source = "/tmp/build/clamshellctl-helper" + let fileSystem = RecordingInstallationFileSystem( + files: [ + source: "helper payload", + PrivilegedPaths.sudoersPolicy: "existing policy", + ], + log: log + ) + let runner = InstallationRecordingRunner( + result: ProcessResult( + standardOutput: "", + standardError: "syntax error", + terminationStatus: 1 + ), + log: log + ) + let installation = PrivilegedInstallation( + fileSystem: fileSystem, + runner: runner, + effectiveUserID: 0, + environment: ["SUDO_USER": "liam"], + executablePath: "/tmp/build/clamshellctl", + temporarySuffix: "test" + ) - #expect(throws: ClamshellError.sudoersValidationFailed) { - try installation.install() - } - #expect(fileSystem.contents(at: PrivilegedPaths.sudoersPolicy) == "existing policy") - #expect( - !log.operations.contains( - .replace( - replacement: "\(PrivilegedPaths.sudoersPolicy).installing.test", - destination: PrivilegedPaths.sudoersPolicy - ) - ) - ) + #expect(throws: ClamshellError.sudoersValidationFailed) { + try installation.install() } - - @Test("removes only managed paths and is safe to repeat") - func uninstall() throws { - let log = InstallationOperationLog() - let unrelated = "/usr/local/bin/clamshellctl" - let fileSystem = RecordingInstallationFileSystem( - files: [ - PrivilegedPaths.helper: "helper", - PrivilegedPaths.sudoersPolicy: "policy", - unrelated: "unrelated", - ], - log: log - ) - let installation = PrivilegedInstallation( - fileSystem: fileSystem, - runner: InstallationRecordingRunner(result: .success, log: log), - effectiveUserID: 0, - environment: [:], - executablePath: "/tmp/build/clamshellctl", - temporarySuffix: "test" + #expect(fileSystem.contents(at: PrivilegedPaths.sudoersPolicy) == "existing policy") + #expect( + !log.operations.contains( + .replace( + replacement: "\(PrivilegedPaths.sudoersPolicy).installing.test", + destination: PrivilegedPaths.sudoersPolicy ) + ) + ) + } - let first = try installation.uninstall() - let second = try installation.uninstall() + @Test("removes only managed paths and is safe to repeat") + func uninstall() throws { + let log = InstallationOperationLog() + let unrelated = "/usr/local/bin/clamshellctl" + let fileSystem = RecordingInstallationFileSystem( + files: [ + PrivilegedPaths.helper: "helper", + PrivilegedPaths.sudoersPolicy: "policy", + unrelated: "unrelated", + ], + log: log + ) + let installation = PrivilegedInstallation( + fileSystem: fileSystem, + runner: InstallationRecordingRunner(result: .success, log: log), + effectiveUserID: 0, + environment: [:], + executablePath: "/tmp/build/clamshellctl", + temporarySuffix: "test" + ) - #expect(first.removedPaths == [PrivilegedPaths.helper, PrivilegedPaths.sudoersPolicy]) - #expect(second.removedPaths.isEmpty) - #expect(fileSystem.contents(at: unrelated) == "unrelated") - } + let first = try installation.uninstall() + let second = try installation.uninstall() + + #expect(first.removedPaths == [PrivilegedPaths.helper, PrivilegedPaths.sudoersPolicy]) + #expect(second.removedPaths.isEmpty) + #expect(fileSystem.contents(at: unrelated) == "unrelated") + } } diff --git a/Tests/ClamshellCoreTests/Privilege/PrivilegedHelperClientTests.swift b/Tests/ClamshellCoreTests/Privilege/PrivilegedHelperClientTests.swift index b643042..fc2cfea 100644 --- a/Tests/ClamshellCoreTests/Privilege/PrivilegedHelperClientTests.swift +++ b/Tests/ClamshellCoreTests/Privilege/PrivilegedHelperClientTests.swift @@ -5,81 +5,81 @@ import Testing @Suite("Privileged helper client") struct PrivilegedHelperClientTests { - @Test( - "uses non-interactive sudo for one exact helper action", - arguments: [ - (ClamshellState.enabled, "enable"), - (.disabled, "disable"), - ] + @Test( + "uses non-interactive sudo for one exact helper action", + arguments: [ + (ClamshellState.enabled, "enable"), + (.disabled, "disable"), + ] + ) + func exactInvocation(state: ClamshellState, action: String) throws { + let runner = HelperRecordingRunner( + result: ProcessResult( + standardOutput: "", + standardError: "", + terminationStatus: 0 + ) ) - func exactInvocation(state: ClamshellState, action: String) throws { - let runner = HelperRecordingRunner( - result: ProcessResult( - standardOutput: "", - standardError: "", - terminationStatus: 0 - ) - ) - - try PrivilegedHelperClient(runner: runner).setState(state) - #expect( - runner.invocations == [ - HelperInvocation( - executable: "/usr/bin/sudo", - arguments: [ - "-n", - "/Library/PrivilegedHelperTools/clamshellctl-helper", - action, - ] - ) - ]) - } + try PrivilegedHelperClient(runner: runner).setState(state) - @Test("replaces sudo failure details with setup guidance") - func sudoFailure() { - let runner = HelperRecordingRunner( - result: ProcessResult( - standardOutput: "arbitrary output", - standardError: "sensitive arbitrary stderr", - terminationStatus: 1 - ) + #expect( + runner.invocations == [ + HelperInvocation( + executable: "/usr/bin/sudo", + arguments: [ + "-n", + "/Library/PrivilegedHelperTools/clamshellctl-helper", + action, + ] ) + ]) + } - #expect( - throws: ClamshellError.privilegedHelperUnavailable( - setupCommand: #"sudo "$(brew --prefix)/bin/clamshellctl" setup"# - ) - ) { - try PrivilegedHelperClient(runner: runner).setState(.enabled) - } + @Test("replaces sudo failure details with setup guidance") + func sudoFailure() { + let runner = HelperRecordingRunner( + result: ProcessResult( + standardOutput: "arbitrary output", + standardError: "sensitive arbitrary stderr", + terminationStatus: 1 + ) + ) + + #expect( + throws: ClamshellError.privilegedHelperUnavailable( + setupCommand: #"sudo "$(brew --prefix)/bin/clamshellctl" setup"# + ) + ) { + try PrivilegedHelperClient(runner: runner).setState(.enabled) } + } } private struct HelperInvocation: Equatable { - let executable: String - let arguments: [String] + let executable: String + let arguments: [String] } private final class HelperRecordingRunner: ProcessRunning, @unchecked Sendable { - private let lock = NSLock() - private let result: ProcessResult - private var recordedInvocations: [HelperInvocation] = [] + private let lock = NSLock() + private let result: ProcessResult + private var recordedInvocations: [HelperInvocation] = [] - init(result: ProcessResult) { - self.result = result - } + init(result: ProcessResult) { + self.result = result + } - var invocations: [HelperInvocation] { - lock.withLock { recordedInvocations } - } + var invocations: [HelperInvocation] { + lock.withLock { recordedInvocations } + } - func run(_ executable: String, arguments: [String]) throws -> ProcessResult { - lock.withLock { - recordedInvocations.append( - HelperInvocation(executable: executable, arguments: arguments) - ) - } - return result + func run(_ executable: String, arguments: [String]) throws -> ProcessResult { + lock.withLock { + recordedInvocations.append( + HelperInvocation(executable: executable, arguments: arguments) + ) } + return result + } } diff --git a/Tests/ClamshellCoreTests/Privilege/SudoersPolicyTests.swift b/Tests/ClamshellCoreTests/Privilege/SudoersPolicyTests.swift index 25f0697..2cc5230 100644 --- a/Tests/ClamshellCoreTests/Privilege/SudoersPolicyTests.swift +++ b/Tests/ClamshellCoreTests/Privilege/SudoersPolicyTests.swift @@ -4,46 +4,46 @@ import Testing @Suite("Sudoers policy") struct SudoersPolicyTests { - @Test( - "accepts safe ASCII account names", - arguments: ["liam", "Liam1", "liam.name", "liam_name", "liam-name"] - ) - func acceptedUsername(username: String) throws { - #expect(try SudoersPolicy(username: username).username == username) - } + @Test( + "accepts safe ASCII account names", + arguments: ["liam", "Liam1", "liam.name", "liam_name", "liam-name"] + ) + func acceptedUsername(username: String) throws { + #expect(try SudoersPolicy(username: username).username == username) + } - @Test("generates only the two exact helper commands") - func exactPolicy() throws { - let policy = try SudoersPolicy(username: "liam") + @Test("generates only the two exact helper commands") + func exactPolicy() throws { + let policy = try SudoersPolicy(username: "liam") - #expect( - policy.contents - == """ - liam ALL=(root) NOPASSWD: /Library/PrivilegedHelperTools/clamshellctl-helper enable - liam ALL=(root) NOPASSWD: /Library/PrivilegedHelperTools/clamshellctl-helper disable + #expect( + policy.contents + == """ + liam ALL=(root) NOPASSWD: /Library/PrivilegedHelperTools/clamshellctl-helper enable + liam ALL=(root) NOPASSWD: /Library/PrivilegedHelperTools/clamshellctl-helper disable - """ - ) - } + """ + ) + } - @Test("rejects unsafe or ambiguous account names") - func rejectedUsername() { - let invalidUsernames = [ - "", - "root", - "liam smith", - "../liam", - "liam/name", - "liam:wheel", - "liam;command", - "liam\nroot ALL=(ALL) ALL", - "líam", - ] + @Test("rejects unsafe or ambiguous account names") + func rejectedUsername() { + let invalidUsernames = [ + "", + "root", + "liam smith", + "../liam", + "liam/name", + "liam:wheel", + "liam;command", + "liam\nroot ALL=(ALL) ALL", + "líam", + ] - for username in invalidUsernames { - #expect(throws: ClamshellError.invalidUsername(username)) { - try SudoersPolicy(username: username) - } - } + for username in invalidUsernames { + #expect(throws: ClamshellError.invalidUsername(username)) { + try SudoersPolicy(username: username) + } } + } } diff --git a/Tests/ClamshellCoreTests/Process/FoundationProcessRunnerTests.swift b/Tests/ClamshellCoreTests/Process/FoundationProcessRunnerTests.swift index 93eaa14..57b5014 100644 --- a/Tests/ClamshellCoreTests/Process/FoundationProcessRunnerTests.swift +++ b/Tests/ClamshellCoreTests/Process/FoundationProcessRunnerTests.swift @@ -4,20 +4,20 @@ import Testing @Suite("Foundation process runner") struct FoundationProcessRunnerTests { - @Test("captures separate output streams and the exit status") - func capturesProcessResult() throws { - let result = try FoundationProcessRunner().run( - "/bin/sh", - arguments: ["-c", "printf output; printf error >&2; exit 7"] - ) + @Test("captures separate output streams and the exit status") + func capturesProcessResult() throws { + let result = try FoundationProcessRunner().run( + "/bin/sh", + arguments: ["-c", "printf output; printf error >&2; exit 7"] + ) - #expect( - result - == ProcessResult( - standardOutput: "output", - standardError: "error", - terminationStatus: 7 - ) + #expect( + result + == ProcessResult( + standardOutput: "output", + standardError: "error", + terminationStatus: 7 ) - } + ) + } } diff --git a/Tests/ClamshellCoreTests/State/ClamshellServiceTests.swift b/Tests/ClamshellCoreTests/State/ClamshellServiceTests.swift index ef0be7b..b98f6be 100644 --- a/Tests/ClamshellCoreTests/State/ClamshellServiceTests.swift +++ b/Tests/ClamshellCoreTests/State/ClamshellServiceTests.swift @@ -5,109 +5,109 @@ import Testing @Suite("Clamshell service") struct ClamshellServiceTests { - @Test( - "applies only required state transitions", - arguments: [ - (ClamshellState.disabled, ClamshellState.disabled, false), - (.disabled, .enabled, true), - (.enabled, .enabled, false), - (.enabled, .disabled, true), - ] - ) - func transition( - current: ClamshellState, - requested: ClamshellState, - shouldMutate: Bool - ) throws { - let power = RecordingPowerSettings(current: current) - let service = ClamshellService(stateReader: power, stateWriter: power) + @Test( + "applies only required state transitions", + arguments: [ + (ClamshellState.disabled, ClamshellState.disabled, false), + (.disabled, .enabled, true), + (.enabled, .enabled, false), + (.enabled, .disabled, true), + ] + ) + func transition( + current: ClamshellState, + requested: ClamshellState, + shouldMutate: Bool + ) throws { + let power = RecordingPowerSettings(current: current) + let service = ClamshellService(stateReader: power, stateWriter: power) - let result = try service.set(requested) + let result = try service.set(requested) - #expect( - result - == TransitionResult( - previous: current, - current: requested, - didChange: shouldMutate - ) + #expect( + result + == TransitionResult( + previous: current, + current: requested, + didChange: shouldMutate ) - #expect(power.requestedStates == (shouldMutate ? [requested] : [])) - } + ) + #expect(power.requestedStates == (shouldMutate ? [requested] : [])) + } - @Test("rejects a mutation that does not reach the requested state") - func verificationFailure() { - let power = RecordingPowerSettings( - current: .disabled, - stateAfterMutation: .disabled - ) - let service = ClamshellService(stateReader: power, stateWriter: power) + @Test("rejects a mutation that does not reach the requested state") + func verificationFailure() { + let power = RecordingPowerSettings( + current: .disabled, + stateAfterMutation: .disabled + ) + let service = ClamshellService(stateReader: power, stateWriter: power) - #expect( - throws: ClamshellError.stateVerificationFailed( - expected: .enabled, - actual: .disabled - ) - ) { - try service.set(.enabled) - } + #expect( + throws: ClamshellError.stateVerificationFailed( + expected: .enabled, + actual: .disabled + ) + ) { + try service.set(.enabled) } + } - @Test("toggles from a fresh state and verifies the result") - func toggle() throws { - let power = RecordingPowerSettings(current: .enabled) - let service = ClamshellService(stateReader: power, stateWriter: power) + @Test("toggles from a fresh state and verifies the result") + func toggle() throws { + let power = RecordingPowerSettings(current: .enabled) + let service = ClamshellService(stateReader: power, stateWriter: power) - let result = try service.toggle() + let result = try service.toggle() - #expect( - result - == TransitionResult( - previous: .enabled, - current: .disabled, - didChange: true - ) + #expect( + result + == TransitionResult( + previous: .enabled, + current: .disabled, + didChange: true ) - #expect(power.requestedStates == [.disabled]) - #expect(power.readCount == 2) - } + ) + #expect(power.requestedStates == [.disabled]) + #expect(power.readCount == 2) + } } private final class RecordingPowerSettings: - PowerStateReading, - PowerStateWriting, - @unchecked Sendable + PowerStateReading, + PowerStateWriting, + @unchecked Sendable { - private let lock = NSLock() - private let stateAfterMutation: ClamshellState? - private var state: ClamshellState - private var recordedStates: [ClamshellState] = [] - private var recordedReadCount = 0 + private let lock = NSLock() + private let stateAfterMutation: ClamshellState? + private var state: ClamshellState + private var recordedStates: [ClamshellState] = [] + private var recordedReadCount = 0 - init(current: ClamshellState, stateAfterMutation: ClamshellState? = nil) { - state = current - self.stateAfterMutation = stateAfterMutation - } + init(current: ClamshellState, stateAfterMutation: ClamshellState? = nil) { + state = current + self.stateAfterMutation = stateAfterMutation + } - var requestedStates: [ClamshellState] { - lock.withLock { recordedStates } - } + var requestedStates: [ClamshellState] { + lock.withLock { recordedStates } + } - var readCount: Int { - lock.withLock { recordedReadCount } - } + var readCount: Int { + lock.withLock { recordedReadCount } + } - func currentState() throws -> ClamshellState { - lock.withLock { - recordedReadCount += 1 - return state - } + func currentState() throws -> ClamshellState { + lock.withLock { + recordedReadCount += 1 + return state } + } - func setState(_ requested: ClamshellState) throws { - lock.withLock { - recordedStates.append(requested) - state = stateAfterMutation ?? requested - } + func setState(_ requested: ClamshellState) throws { + lock.withLock { + recordedStates.append(requested) + state = stateAfterMutation ?? requested } + } } diff --git a/Tests/ClamshellCoreTests/Support/InstallationTestSupport.swift b/Tests/ClamshellCoreTests/Support/InstallationTestSupport.swift index 1ae365c..380f288 100644 --- a/Tests/ClamshellCoreTests/Support/InstallationTestSupport.swift +++ b/Tests/ClamshellCoreTests/Support/InstallationTestSupport.swift @@ -3,183 +3,183 @@ import Foundation @testable import ClamshellCore enum InstallationOperation: Equatable { - case itemExists(String) - case isRegularFile(String) - case contentsEqual(firstPath: String, secondPath: String) - case readText(String) - case attributes(String) - case copy(source: String, destination: String) - case setOwner(path: String, userID: UInt32, groupID: UInt32) - case setPermissions(path: String, permissions: UInt16) - case replace(replacement: String, destination: String) - case write(contents: String, path: String) - case remove(String) - case run(executable: String, arguments: [String]) + case itemExists(String) + case isRegularFile(String) + case contentsEqual(firstPath: String, secondPath: String) + case readText(String) + case attributes(String) + case copy(source: String, destination: String) + case setOwner(path: String, userID: UInt32, groupID: UInt32) + case setPermissions(path: String, permissions: UInt16) + case replace(replacement: String, destination: String) + case write(contents: String, path: String) + case remove(String) + case run(executable: String, arguments: [String]) } final class InstallationOperationLog: @unchecked Sendable { - private let lock = NSLock() - private var recordedOperations: [InstallationOperation] = [] + private let lock = NSLock() + private var recordedOperations: [InstallationOperation] = [] - var operations: [InstallationOperation] { - lock.withLock { recordedOperations } - } + var operations: [InstallationOperation] { + lock.withLock { recordedOperations } + } - func append(_ operation: InstallationOperation) { - lock.withLock { - recordedOperations.append(operation) - } + func append(_ operation: InstallationOperation) { + lock.withLock { + recordedOperations.append(operation) } + } } final class RecordingInstallationFileSystem: - InstallationFileSystem, - @unchecked Sendable + InstallationFileSystem, + @unchecked Sendable { - private let lock = NSLock() - private let log: InstallationOperationLog - private var files: [String: String] - private var fileAttributes: [String: InstalledFileAttributes] - - init(files: [String: String], log: InstallationOperationLog) { - self.files = files - fileAttributes = Dictionary( - uniqueKeysWithValues: files.keys.map { - ($0, InstalledFileAttributes(userID: 501, groupID: 20, permissions: 0o755)) - } - ) - self.log = log - } - - func itemExists(at path: String) -> Bool { - log.append(.itemExists(path)) - return lock.withLock { files[path] != nil } - } - - func isRegularFile(at path: String) -> Bool { - log.append(.isRegularFile(path)) - return lock.withLock { files[path] != nil } - } - - func contentsEqual(at firstPath: String, and secondPath: String) -> Bool { - log.append(.contentsEqual(firstPath: firstPath, secondPath: secondPath)) - return lock.withLock { files[firstPath] == files[secondPath] } - } - - func readText(at path: String) throws -> String { - log.append(.readText(path)) - return try lock.withLock { - guard let contents = files[path] else { - throw ClamshellError.installationVerificationFailed - } - return contents - } - } - - func attributes(at path: String) throws -> InstalledFileAttributes { - log.append(.attributes(path)) - return try lock.withLock { - guard let attributes = fileAttributes[path] else { - throw ClamshellError.installationVerificationFailed - } - return attributes - } - } - - func copyItem(at source: String, to destination: String) throws { - log.append(.copy(source: source, destination: destination)) - try lock.withLock { - guard let contents = files[source] else { - throw ClamshellError.helperPayloadNotFound - } - files[destination] = contents - fileAttributes[destination] = fileAttributes[source] - } - } - - func write(_ contents: String, to path: String) throws { - log.append(.write(contents: contents, path: path)) - lock.withLock { - files[path] = contents - fileAttributes[path] = InstalledFileAttributes( - userID: 0, - groupID: 0, - permissions: 0o600 - ) - } - } - - func setOwner(userID: UInt32, groupID: UInt32, at path: String) throws { - log.append(.setOwner(path: path, userID: userID, groupID: groupID)) - try lock.withLock { - guard let attributes = fileAttributes[path] else { - throw ClamshellError.installationVerificationFailed - } - fileAttributes[path] = InstalledFileAttributes( - userID: userID, - groupID: groupID, - permissions: attributes.permissions - ) - } - } - - func setPermissions(_ permissions: UInt16, at path: String) throws { - log.append(.setPermissions(path: path, permissions: permissions)) - try lock.withLock { - guard let attributes = fileAttributes[path] else { - throw ClamshellError.installationVerificationFailed - } - fileAttributes[path] = InstalledFileAttributes( - userID: attributes.userID, - groupID: attributes.groupID, - permissions: permissions - ) - } - } - - func replaceItem(at destination: String, withItemAt replacement: String) throws { - log.append(.replace(replacement: replacement, destination: destination)) - try lock.withLock { - guard let contents = files.removeValue(forKey: replacement) else { - throw ClamshellError.helperPayloadNotFound - } - files[destination] = contents - fileAttributes[destination] = fileAttributes.removeValue(forKey: replacement) - } - } - - func removeItem(at path: String) throws { - log.append(.remove(path)) - lock.withLock { - _ = files.removeValue(forKey: path) - _ = fileAttributes.removeValue(forKey: path) - } - } - - func contents(at path: String) -> String? { - lock.withLock { files[path] } - } + private let lock = NSLock() + private let log: InstallationOperationLog + private var files: [String: String] + private var fileAttributes: [String: InstalledFileAttributes] + + init(files: [String: String], log: InstallationOperationLog) { + self.files = files + fileAttributes = Dictionary( + uniqueKeysWithValues: files.keys.map { + ($0, InstalledFileAttributes(userID: 501, groupID: 20, permissions: 0o755)) + } + ) + self.log = log + } + + func itemExists(at path: String) -> Bool { + log.append(.itemExists(path)) + return lock.withLock { files[path] != nil } + } + + func isRegularFile(at path: String) -> Bool { + log.append(.isRegularFile(path)) + return lock.withLock { files[path] != nil } + } + + func contentsEqual(at firstPath: String, and secondPath: String) -> Bool { + log.append(.contentsEqual(firstPath: firstPath, secondPath: secondPath)) + return lock.withLock { files[firstPath] == files[secondPath] } + } + + func readText(at path: String) throws -> String { + log.append(.readText(path)) + return try lock.withLock { + guard let contents = files[path] else { + throw ClamshellError.installationVerificationFailed + } + return contents + } + } + + func attributes(at path: String) throws -> InstalledFileAttributes { + log.append(.attributes(path)) + return try lock.withLock { + guard let attributes = fileAttributes[path] else { + throw ClamshellError.installationVerificationFailed + } + return attributes + } + } + + func copyItem(at source: String, to destination: String) throws { + log.append(.copy(source: source, destination: destination)) + try lock.withLock { + guard let contents = files[source] else { + throw ClamshellError.helperPayloadNotFound + } + files[destination] = contents + fileAttributes[destination] = fileAttributes[source] + } + } + + func write(_ contents: String, to path: String) throws { + log.append(.write(contents: contents, path: path)) + lock.withLock { + files[path] = contents + fileAttributes[path] = InstalledFileAttributes( + userID: 0, + groupID: 0, + permissions: 0o600 + ) + } + } + + func setOwner(userID: UInt32, groupID: UInt32, at path: String) throws { + log.append(.setOwner(path: path, userID: userID, groupID: groupID)) + try lock.withLock { + guard let attributes = fileAttributes[path] else { + throw ClamshellError.installationVerificationFailed + } + fileAttributes[path] = InstalledFileAttributes( + userID: userID, + groupID: groupID, + permissions: attributes.permissions + ) + } + } + + func setPermissions(_ permissions: UInt16, at path: String) throws { + log.append(.setPermissions(path: path, permissions: permissions)) + try lock.withLock { + guard let attributes = fileAttributes[path] else { + throw ClamshellError.installationVerificationFailed + } + fileAttributes[path] = InstalledFileAttributes( + userID: attributes.userID, + groupID: attributes.groupID, + permissions: permissions + ) + } + } + + func replaceItem(at destination: String, withItemAt replacement: String) throws { + log.append(.replace(replacement: replacement, destination: destination)) + try lock.withLock { + guard let contents = files.removeValue(forKey: replacement) else { + throw ClamshellError.helperPayloadNotFound + } + files[destination] = contents + fileAttributes[destination] = fileAttributes.removeValue(forKey: replacement) + } + } + + func removeItem(at path: String) throws { + log.append(.remove(path)) + lock.withLock { + _ = files.removeValue(forKey: path) + _ = fileAttributes.removeValue(forKey: path) + } + } + + func contents(at path: String) -> String? { + lock.withLock { files[path] } + } } final class InstallationRecordingRunner: ProcessRunning, @unchecked Sendable { - private let result: ProcessResult - private let log: InstallationOperationLog - - init(result: ProcessResult, log: InstallationOperationLog) { - self.result = result - self.log = log - } - - func run(_ executable: String, arguments: [String]) throws -> ProcessResult { - log.append(.run(executable: executable, arguments: arguments)) - return result - } + private let result: ProcessResult + private let log: InstallationOperationLog + + init(result: ProcessResult, log: InstallationOperationLog) { + self.result = result + self.log = log + } + + func run(_ executable: String, arguments: [String]) throws -> ProcessResult { + log.append(.run(executable: executable, arguments: arguments)) + return result + } } extension ProcessResult { - static let success = ProcessResult( - standardOutput: "", - standardError: "", - terminationStatus: 0 - ) + static let success = ProcessResult( + standardOutput: "", + standardError: "", + terminationStatus: 0 + ) } From dffb9b4905d53ece2f242a581aeb670ef4c141f5 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:25:41 +0100 Subject: [PATCH 14/47] build(lint): enforce Swift conventions --- .swift-format | 4 +- .swiftlint.yml | 74 ++++++++++++++++ Package.resolved | 11 ++- Package.swift | 6 +- .../ClamshellCore/Errors/ClamshellError.swift | 10 +-- .../ClamshellCore/Power/PowerMutation.swift | 1 + .../Power/PowerSettingsParser.swift | 1 + .../Installation/PrivilegedInstallation.swift | 88 +++++++++---------- .../Privilege/PrivilegedHelperClient.swift | 1 + .../Privilege/SudoersPolicy.swift | 2 + .../State/ClamshellService.swift | 2 + .../PrivilegedInstallationTests.swift | 85 +++++++++--------- docs/repository-quality-design.md | 9 +- .../repository-quality-implementation-plan.md | 50 +++++------ 14 files changed, 217 insertions(+), 127 deletions(-) create mode 100644 .swiftlint.yml diff --git a/.swift-format b/.swift-format index 4e33472..5076810 100644 --- a/.swift-format +++ b/.swift-format @@ -6,10 +6,12 @@ "maximumBlankLines": 1, "multiElementCollectionTrailingCommas": true, "rules": { + "BeginDocumentationCommentWithOneLineSummary": true, "NeverForceUnwrap": true, "NeverUseForceTry": true, "NeverUseImplicitlyUnwrappedOptionals": true, - "OrderedImports": true + "OrderedImports": true, + "ValidateDocumentationComments": true }, "version": 1 } diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..afd9986 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,74 @@ +strict: true +check_for_updates: false +allow_zero_lintable_files: false + +excluded: + - .build + - .swiftpm + - Clamshell.xcodeproj + - DerivedData + +disabled_rules: + - closing_brace + - colon + - comma + - leading_whitespace + - line_length + - opening_brace + - function_name_whitespace + - return_arrow_whitespace + - statement_position + - force_try + - trailing_comma + - trailing_newline + - trailing_semicolon + - trailing_whitespace + - vertical_whitespace + +opt_in_rules: + - array_init + - closure_body_length + - contains_over_filter_count + - contains_over_filter_is_empty + - discouraged_optional_boolean + - empty_collection_literal + - empty_count + - empty_string + - explicit_init + - fatal_error_message + - first_where + - last_where + - legacy_multiple + - modifier_order + - overridden_super_call + - pattern_matching_keywords + - reduce_into + - redundant_nil_coalescing + - sorted_first_last + - toggle_bool + - unavailable_function + +analyzer_rules: + - unused_import + +closure_body_length: + warning: 40 + error: 60 +cyclomatic_complexity: + warning: 10 + error: 15 +file_length: + warning: 400 + error: 500 + ignore_comment_only_lines: true +function_body_length: + warning: 40 + error: 60 +function_parameter_count: + warning: 6 + error: 8 +type_body_length: + warning: 250 + error: 350 + +reporter: xcode diff --git a/Package.resolved b/Package.resolved index a68d9fb..b4a3d6d 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b1c79db6518fc27a00cb062a81a446b419cf3d099840104ce61b1bcc12f22936", + "originHash" : "84d535480b2e1e6cd6341ccce330d6f41d7f10625f9ac0bf6753df9d8c1964d0", "pins" : [ { "identity" : "swift-argument-parser", @@ -9,6 +9,15 @@ "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", "version" : "1.8.2" } + }, + { + "identity" : "swiftlintplugins", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SimplyDanny/SwiftLintPlugins", + "state" : { + "revision" : "8a4640d14777685ba8f14e832373160498fbab92", + "version" : "0.63.2" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index 1e9727e..e5a49c9 100644 --- a/Package.swift +++ b/Package.swift @@ -13,7 +13,11 @@ let package = Package( .package( url: "https://github.com/apple/swift-argument-parser", from: "1.8.0" - ) + ), + .package( + url: "https://github.com/SimplyDanny/SwiftLintPlugins", + exact: "0.63.2" + ), ], targets: [ .target(name: "ClamshellCore"), diff --git a/Sources/ClamshellCore/Errors/ClamshellError.swift b/Sources/ClamshellCore/Errors/ClamshellError.swift index db4420e..ee4ffd6 100644 --- a/Sources/ClamshellCore/Errors/ClamshellError.swift +++ b/Sources/ClamshellCore/Errors/ClamshellError.swift @@ -21,7 +21,7 @@ public enum ClamshellError: Error, Equatable, LocalizedError { public var errorDescription: String? { switch self { - case .administratorPrivilegesRequired(let command): + case let .administratorPrivilegesRequired(command): "Administrator privileges are required. Run: \(command)" case .executablePathUnavailable: "Unable to resolve the clamshellctl executable path." @@ -31,21 +31,21 @@ public enum ClamshellError: Error, Equatable, LocalizedError { "The privileged installation could not be verified." case .invalidHelperArguments: "Invalid privileged helper arguments." - case .invalidProcessOutput(let executable, let stream): + case let .invalidProcessOutput(executable, stream): "Unable to decode \(stream.rawValue) from \(executable)." case .invalidUsername: "The original account name is unavailable or unsafe." case .originalUserUnavailable: "Unable to identify the account that requested setup." - case .privilegedHelperUnavailable(let setupCommand): + case let .privilegedHelperUnavailable(setupCommand): "Privileged helper unavailable. Run: \(setupCommand)" - case .processFailed(let executable, let terminationStatus, let standardError): + case let .processFailed(executable, terminationStatus, standardError): processFailureDescription( executable: executable, terminationStatus: terminationStatus, standardError: standardError ) - case .stateVerificationFailed(let expected, let actual): + case let .stateVerificationFailed(expected, actual): "Battery clamshell mode verification failed: expected \(expected.rawValue), found \(actual.rawValue)." case .sudoersValidationFailed: "The generated sudoers policy failed validation." diff --git a/Sources/ClamshellCore/Power/PowerMutation.swift b/Sources/ClamshellCore/Power/PowerMutation.swift index 9d45f50..abb70e5 100644 --- a/Sources/ClamshellCore/Power/PowerMutation.swift +++ b/Sources/ClamshellCore/Power/PowerMutation.swift @@ -1,6 +1,7 @@ public struct PowerMutation: Sendable, Equatable { public let state: ClamshellState + /// Accepts exactly one `enable` or `disable` argument. public init(rawArguments: [String]) throws { switch rawArguments { case ["enable"]: diff --git a/Sources/ClamshellCore/Power/PowerSettingsParser.swift b/Sources/ClamshellCore/Power/PowerSettingsParser.swift index 45a0139..d96e498 100644 --- a/Sources/ClamshellCore/Power/PowerSettingsParser.swift +++ b/Sources/ClamshellCore/Power/PowerSettingsParser.swift @@ -3,6 +3,7 @@ import Foundation public struct PowerSettingsParser: Sendable { public init() {} + /// Treats a missing `disablesleep` entry as disabled and rejects malformed values. public func batteryState(from output: String) throws -> ClamshellState { let lines = output.split( omittingEmptySubsequences: false, diff --git a/Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift b/Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift index d112b37..3d1f126 100644 --- a/Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift +++ b/Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift @@ -35,6 +35,7 @@ public struct PrivilegedInstallation: Sendable { ) } + /// Stages, validates, and verifies root-owned files, skipping an identical installation. public func install() throws -> InstallationResult { try requireAdministratorPrivileges() @@ -52,50 +53,8 @@ public struct PrivilegedInstallation: Sendable { ) } - let helperTemporary = temporaryPath(for: PrivilegedPaths.helper) - var helperTemporaryExists = false - defer { - if helperTemporaryExists { - try? fileSystem.removeItem(at: helperTemporary) - } - } - - try fileSystem.copyItem(at: payload, to: helperTemporary) - helperTemporaryExists = true - try fileSystem.setOwner(userID: 0, groupID: 0, at: helperTemporary) - try fileSystem.setPermissions(0o755, at: helperTemporary) - try fileSystem.replaceItem( - at: PrivilegedPaths.helper, - withItemAt: helperTemporary - ) - helperTemporaryExists = false - - let policyTemporary = temporaryPath(for: PrivilegedPaths.sudoersPolicy) - var policyTemporaryExists = false - defer { - if policyTemporaryExists { - try? fileSystem.removeItem(at: policyTemporary) - } - } - - try fileSystem.write(policy.contents, to: policyTemporary) - policyTemporaryExists = true - try fileSystem.setOwner(userID: 0, groupID: 0, at: policyTemporary) - try fileSystem.setPermissions(0o440, at: policyTemporary) - - let validation = try runner.run( - "/usr/sbin/visudo", - arguments: ["-cf", policyTemporary] - ) - guard validation.terminationStatus == 0 else { - throw ClamshellError.sudoersValidationFailed - } - - try fileSystem.replaceItem( - at: PrivilegedPaths.sudoersPolicy, - withItemAt: policyTemporary - ) - policyTemporaryExists = false + try installHelper(payload: payload) + try installPolicy(policy) guard try isConfigured(payload: payload, policy: policy) else { throw ClamshellError.installationVerificationFailed @@ -108,6 +67,7 @@ public struct PrivilegedInstallation: Sendable { ) } + /// Removes only the two managed installation paths and is safe to repeat. public func uninstall() throws -> UninstallationResult { try requireAdministratorPrivileges(command: PrivilegedHelperClient.uninstallCommand) @@ -124,6 +84,46 @@ public struct PrivilegedInstallation: Sendable { try requireAdministratorPrivileges(command: PrivilegedHelperClient.setupCommand) } + private func installHelper(payload: String) throws { + let temporary = temporaryPath(for: PrivilegedPaths.helper) + var temporaryExists = false + defer { + if temporaryExists { + try? fileSystem.removeItem(at: temporary) + } + } + + try fileSystem.copyItem(at: payload, to: temporary) + temporaryExists = true + try fileSystem.setOwner(userID: 0, groupID: 0, at: temporary) + try fileSystem.setPermissions(0o755, at: temporary) + try fileSystem.replaceItem(at: PrivilegedPaths.helper, withItemAt: temporary) + temporaryExists = false + } + + private func installPolicy(_ policy: SudoersPolicy) throws { + let temporary = temporaryPath(for: PrivilegedPaths.sudoersPolicy) + var temporaryExists = false + defer { + if temporaryExists { + try? fileSystem.removeItem(at: temporary) + } + } + + try fileSystem.write(policy.contents, to: temporary) + temporaryExists = true + try fileSystem.setOwner(userID: 0, groupID: 0, at: temporary) + try fileSystem.setPermissions(0o440, at: temporary) + + let validation = try runner.run("/usr/sbin/visudo", arguments: ["-cf", temporary]) + guard validation.terminationStatus == 0 else { + throw ClamshellError.sudoersValidationFailed + } + + try fileSystem.replaceItem(at: PrivilegedPaths.sudoersPolicy, withItemAt: temporary) + temporaryExists = false + } + private func requireAdministratorPrivileges(command: String) throws { guard effectiveUserID == 0 else { throw ClamshellError.administratorPrivilegesRequired( diff --git a/Sources/ClamshellCore/Privilege/PrivilegedHelperClient.swift b/Sources/ClamshellCore/Privilege/PrivilegedHelperClient.swift index 3881409..962fa83 100644 --- a/Sources/ClamshellCore/Privilege/PrivilegedHelperClient.swift +++ b/Sources/ClamshellCore/Privilege/PrivilegedHelperClient.swift @@ -9,6 +9,7 @@ public struct PrivilegedHelperClient: PowerStateWriting, Sendable { self.runner = runner } + /// Invokes only the installed helper's `enable` or `disable` action without prompting. public func setState(_ state: ClamshellState) throws { let action = state == .enabled ? "enable" : "disable" let result = try runner.run( diff --git a/Sources/ClamshellCore/Privilege/SudoersPolicy.swift b/Sources/ClamshellCore/Privilege/SudoersPolicy.swift index 9f19fa8..cdc0f40 100644 --- a/Sources/ClamshellCore/Privilege/SudoersPolicy.swift +++ b/Sources/ClamshellCore/Privilege/SudoersPolicy.swift @@ -1,6 +1,7 @@ public struct SudoersPolicy: Sendable, Equatable { public let username: String + /// Rejects root, empty names, and characters that could alter sudoers syntax. public init(username: String) throws { guard username != "root", !username.isEmpty, username.unicodeScalars.allSatisfy(Self.isSafe) else { @@ -9,6 +10,7 @@ public struct SudoersPolicy: Sendable, Equatable { self.username = username } + /// Permits only the installed helper's `enable` and `disable` actions. public var contents: String { """ \(username) ALL=(root) NOPASSWD: \(PrivilegedPaths.helper) enable diff --git a/Sources/ClamshellCore/State/ClamshellService.swift b/Sources/ClamshellCore/State/ClamshellService.swift index 000207b..7fde67c 100644 --- a/Sources/ClamshellCore/State/ClamshellService.swift +++ b/Sources/ClamshellCore/State/ClamshellService.swift @@ -10,11 +10,13 @@ public struct ClamshellService: Sendable { self.stateWriter = stateWriter } + /// Avoids redundant writes and verifies the system state after a change. public func set(_ requested: ClamshellState) throws -> TransitionResult { let current = try stateReader.currentState() return try set(requested, from: current) } + /// Reads the current state once, then applies and verifies its opposite. public func toggle() throws -> TransitionResult { let current = try stateReader.currentState() let requested: ClamshellState = current == .enabled ? .disabled : .enabled diff --git a/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationTests.swift b/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationTests.swift index 680a46b..df6ca99 100644 --- a/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationTests.swift +++ b/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationTests.swift @@ -13,14 +13,7 @@ struct PrivilegedInstallationTests { files: [source: "helper payload"], log: log ) - let runner = InstallationRecordingRunner( - result: ProcessResult( - standardOutput: "", - standardError: "", - terminationStatus: 0 - ), - log: log - ) + let runner = InstallationRecordingRunner(result: .success, log: log) let installation = PrivilegedInstallation( fileSystem: fileSystem, runner: runner, @@ -42,41 +35,13 @@ struct PrivilegedInstallationTests { ) ) #expect( - log.operations == [ - .isRegularFile(source), - .isRegularFile(PrivilegedPaths.helper), - .copy(source: source, destination: helperTemporary), - .setOwner(path: helperTemporary, userID: 0, groupID: 0), - .setPermissions(path: helperTemporary, permissions: 0o755), - .replace(replacement: helperTemporary, destination: PrivilegedPaths.helper), - .write( - contents: try SudoersPolicy(username: "liam").contents, - path: policyTemporary - ), - .setOwner(path: policyTemporary, userID: 0, groupID: 0), - .setPermissions(path: policyTemporary, permissions: 0o440), - .run( - executable: "/usr/sbin/visudo", - arguments: ["-cf", policyTemporary] - ), - .replace( - replacement: policyTemporary, - destination: PrivilegedPaths.sudoersPolicy - ), - .isRegularFile(PrivilegedPaths.helper), - .contentsEqual( - firstPath: source, - secondPath: PrivilegedPaths.helper - ), - .attributes(PrivilegedPaths.helper), - .isRegularFile(PrivilegedPaths.sudoersPolicy), - .readText(PrivilegedPaths.sudoersPolicy), - .attributes(PrivilegedPaths.sudoersPolicy), - .run( - executable: "/usr/sbin/visudo", - arguments: ["-cf", PrivilegedPaths.sudoersPolicy] - ), - ] + log.operations + == expectedInstallOperations( + source: source, + helperTemporary: helperTemporary, + policyTemporary: policyTemporary, + policyContents: try SudoersPolicy(username: "liam").contents + ) ) } @@ -261,4 +226,38 @@ struct PrivilegedInstallationTests { #expect(second.removedPaths.isEmpty) #expect(fileSystem.contents(at: unrelated) == "unrelated") } + + private func expectedInstallOperations( + source: String, + helperTemporary: String, + policyTemporary: String, + policyContents: String + ) -> [InstallationOperation] { + [ + .isRegularFile(source), + .isRegularFile(PrivilegedPaths.helper), + .copy(source: source, destination: helperTemporary), + .setOwner(path: helperTemporary, userID: 0, groupID: 0), + .setPermissions(path: helperTemporary, permissions: 0o755), + .replace(replacement: helperTemporary, destination: PrivilegedPaths.helper), + .write(contents: policyContents, path: policyTemporary), + .setOwner(path: policyTemporary, userID: 0, groupID: 0), + .setPermissions(path: policyTemporary, permissions: 0o440), + .run(executable: "/usr/sbin/visudo", arguments: ["-cf", policyTemporary]), + .replace( + replacement: policyTemporary, + destination: PrivilegedPaths.sudoersPolicy + ), + .isRegularFile(PrivilegedPaths.helper), + .contentsEqual(firstPath: source, secondPath: PrivilegedPaths.helper), + .attributes(PrivilegedPaths.helper), + .isRegularFile(PrivilegedPaths.sudoersPolicy), + .readText(PrivilegedPaths.sudoersPolicy), + .attributes(PrivilegedPaths.sudoersPolicy), + .run( + executable: "/usr/sbin/visudo", + arguments: ["-cf", PrivilegedPaths.sudoersPolicy] + ), + ] + } } diff --git a/docs/repository-quality-design.md b/docs/repository-quality-design.md index 7e6caa7..99f2f12 100644 --- a/docs/repository-quality-design.md +++ b/docs/repository-quality-design.md @@ -117,10 +117,11 @@ System operations remain injectable. Tests must not invoke real `pmset`, such as `Utils`, `Common`, or `Models` are prohibited. Access control is as narrow as target boundaries permit. Declarations exposed -to the future app package product are public and have concise documentation. -Other declarations remain internal or private. Documentation describes -contracts, constraints, and failure behaviour; implementation comments explain -non-obvious decisions instead of restating code. +to the future app package product are public. Other declarations remain +internal or private. Documentation is added when it records contracts, +constraints, or failure behaviour that the declaration does not make clear; +implementation comments explain non-obvious decisions instead of restating +code. The refactor does not change command output, exit codes, accepted arguments, privileged paths, or the sudoers allow-list unless a separate behaviour change diff --git a/docs/repository-quality-implementation-plan.md b/docs/repository-quality-implementation-plan.md index b48711a..ec69fb9 100644 --- a/docs/repository-quality-implementation-plan.md +++ b/docs/repository-quality-implementation-plan.md @@ -475,7 +475,7 @@ git add .editorconfig .gitattributes .gitignore .swift-format Package.swift Sour git commit -m "style(swift): adopt Google formatting" ``` -## Task 5: Add strict SwiftLint and public API documentation +## Task 5: Add strict SwiftLint and useful API documentation **Files:** @@ -508,7 +508,6 @@ First extend the `rules` object in `.swift-format` with the documentation rules that become enforceable in this task: ```json -"AllPublicDeclarationsHaveDocumentation": true, "BeginDocumentationCommentWithOneLineSummary": true, "ValidateDocumentationComments": true ``` @@ -533,10 +532,11 @@ disabled_rules: - leading_whitespace - line_length - opening_brace - - operator_whitespace + - function_name_whitespace - return_arrow_whitespace - statement_position - force_try + - trailing_comma - trailing_newline - trailing_semicolon - trailing_whitespace @@ -544,6 +544,7 @@ disabled_rules: opt_in_rules: - array_init + - closure_body_length - contains_over_filter_count - contains_over_filter_is_empty - discouraged_optional_boolean @@ -563,6 +564,8 @@ opt_in_rules: - sorted_first_last - toggle_bool - unavailable_function + +analyzer_rules: - unused_import closure_body_length: @@ -593,39 +596,30 @@ reporter: xcode Run: ```bash -swift package plugin swiftlint --strict +swift package plugin --allow-writing-to-package-directory swiftlint --strict ``` Expected: non-zero until every reported correctness, naming, complexity, and unused-code violation is addressed. Do not create a baseline or disable a rule for the whole repository. -- [ ] **Step 4: Document every public contract** +- [ ] **Step 4: Document non-obvious public contracts** -Add concise `///` documentation to each public type, initialiser, property, and -method. The one-line summaries must communicate these exact contracts: +Add concise `///` documentation only where it records behaviour that a +self-describing declaration does not communicate. Cover these contracts: ```text -ClamshellState Whether battery clamshell mode is enabled. -PowerStateReading/Writing Read or request the system state. -TransitionResult Previous, verified current, and change status. -PowerSettingsParser Parse the Battery Power section from pmset. -PowerSettingsClient Read and mutate battery pmset settings. -ProcessRunning/ProcessResult Execute a process and capture both streams. -FoundationProcessRunner Foundation-backed ProcessRunning adapter. -PrivilegedHelperClient Request one allow-listed helper mutation. -PrivilegedPaths Fixed root-owned installation destinations. -SudoersPolicy Validate a username and generate exact policy. -InstallationFileSystem Filesystem operations required by installation. -FoundationInstallationFileSystem Foundation and POSIX filesystem adapter. -PrivilegedInstallation Install, verify, or remove privileged files. -ClamshellService Apply idempotent, verified state transitions. -BuildVersion Release-please-managed package version. +PowerMutation Accept only one exact helper action. +PowerSettingsParser Define missing and malformed pmset behaviour. +PrivilegedHelperClient Restrict non-interactive helper invocation. +SudoersPolicy Reject unsafe usernames and allow only exact commands. +PrivilegedInstallation Preserve staging, validation, verification, and idempotency. +ClamshellService Avoid redundant writes and verify state transitions. ``` -Document associated values and failure behaviour where a caller needs that -information. Do not add comments to private implementation details unless the -decision is non-obvious. +Do not document obvious properties, trivial initialisers, or declarations whose +names and types already state their complete contract. Do not add comments to +private implementation details unless the decision is non-obvious. - [ ] **Step 5: Resolve every strict lint finding idiomatically** @@ -636,7 +630,7 @@ Run after each group of fixes: ```bash swift format --recursive --in-place Sources Tests Package.swift swift format lint --recursive --strict Sources Tests Package.swift -swift package plugin swiftlint --strict +swift package plugin --allow-writing-to-package-directory swiftlint --strict swift test ``` @@ -704,7 +698,7 @@ if [[ -d App ]]; then fi swift format lint --recursive --strict "${swift_paths[@]}" -swift package plugin swiftlint --strict +swift package plugin --allow-writing-to-package-directory swiftlint --strict swift test swift build swift build -c release @@ -789,7 +783,7 @@ jobs: with: persist-credentials: false - run: swift format lint --recursive --strict Sources Tests Package.swift - - run: swift package plugin swiftlint --strict + - run: swift package plugin --allow-writing-to-package-directory swiftlint --strict tests: name: Tests From 452a9a5dd35c2b4609f5107d09d91d24462421ca Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:26:35 +0100 Subject: [PATCH 15/47] build(checks): add local verification entry point --- scripts/check-conventional-subject.sh | 11 ++++++++ scripts/check.sh | 38 +++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100755 scripts/check-conventional-subject.sh create mode 100755 scripts/check.sh diff --git a/scripts/check-conventional-subject.sh b/scripts/check-conventional-subject.sh new file mode 100755 index 0000000..c7446ec --- /dev/null +++ b/scripts/check-conventional-subject.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -euo pipefail + +readonly subject="${1:-}" +readonly verbs='feat|fix|docs|test|build|ci|refactor|perf|style|chore|revert' +readonly pattern="^(${verbs})\([a-z0-9][a-z0-9-]*\): .+" + +if [[ ! "$subject" =~ $pattern ]]; then + echo "Expected verb(area): description, received: $subject" >&2 + exit 1 +fi diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..9955dda --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,38 @@ +#!/bin/bash +set -euo pipefail + +readonly repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repository_root" + +swift_paths=(Package.swift Sources Tests) +if [[ -d App ]]; then + swift_paths+=(App) +fi + +swift format lint --recursive --strict "${swift_paths[@]}" +swift package plugin --allow-writing-to-package-directory swiftlint --strict +swift test +swift build +swift build -c release + +if [[ -d .github/workflows ]]; then + command -v actionlint >/dev/null || { + echo "actionlint is required: brew install actionlint" >&2 + exit 1 + } + actionlint +fi + +if [[ -f project.yml ]]; then + command -v xcodegen >/dev/null || { + echo "XcodeGen is required: brew install xcodegen" >&2 + exit 1 + } + xcodegen generate + xcodebuild \ + -project Clamshell.xcodeproj \ + -scheme ClamshellApp \ + -configuration Debug \ + CODE_SIGNING_ALLOWED=NO \ + build +fi From 7d5be2403e689df46c8d9fad9a01d1bcc2334bd8 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:27:12 +0100 Subject: [PATCH 16/47] ci(checks): enforce repository quality --- .github/workflows/ci.yml | 60 ++++++++++++++++++++++++++++++++++++++++ .github/workflows/pr.yml | 39 ++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/pr.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5921c6a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + quality: + name: Quality + runs-on: macos-26 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: swift format lint --recursive --strict Sources Tests Package.swift + - run: swift package plugin --allow-writing-to-package-directory swiftlint --strict + + tests: + name: Tests + runs-on: macos-26 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: swift test + + build: + name: Build + runs-on: macos-26 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: swift build + - run: swift build -c release + + workflows: + name: Workflow lint + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0 + with: + version: 1.7.12 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..e794097 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,39 @@ +name: PR + +on: + pull_request: + branches: [main] + types: [opened, edited, reopened, synchronize] + +permissions: + contents: read + +jobs: + title: + name: Title + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: scripts/check-conventional-subject.sh "$PR_TITLE" + + commits: + name: Commits + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha }} + - env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + while IFS= read -r subject; do + scripts/check-conventional-subject.sh "$subject" + done < <(git log --format=%s "$BASE_SHA..HEAD") From 03d2c8b5ea486c17f68caf640379db98eb4b00b4 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:27:56 +0100 Subject: [PATCH 17/47] ci(release): add security and release automation --- .github/dependabot.yml | 27 +++++++++++++++++++++++++ .github/workflows/codeql.yml | 37 +++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 28 ++++++++++++++++++++++++++ .release-please-manifest.json | 3 +++ CHANGELOG.md | 3 +++ release-please-config.json | 32 ++++++++++++++++++++++++++++++ 6 files changed, 130 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/release.yml create mode 100644 .release-please-manifest.json create mode 100644 CHANGELOG.md create mode 100644 release-please-config.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3ed1fd7 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,27 @@ +version: 2 +updates: + - package-ecosystem: swift + directory: / + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Europe/London + open-pull-requests-limit: 5 + groups: + swift-development: + patterns: + - "*" + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Europe/London + open-pull-requests-limit: 5 + groups: + actions: + patterns: + - "*" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..b393b5c --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,37 @@ +name: CodeQL + +on: + pull_request: + branches: [main] + push: + branches: [main] + schedule: + - cron: "23 4 * * 1" + workflow_dispatch: + +concurrency: + group: codeql-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + security-events: write + +jobs: + analyze: + name: Analyze (swift) + runs-on: macos-26 + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 + with: + build-mode: manual + languages: swift + queries: security-extended + - run: swift build + - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 + with: + category: /language:swift diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b7ec825 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,28 @@ +name: Release + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + release-please: + name: Release Please + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + issues: write + pull-requests: write + steps: + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5 + with: + token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..e18ee07 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5aaac2a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +Notable changes to `clamshellctl` are recorded here by release-please. diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..c9439f8 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "simple", + "package-name": "clamshellctl", + "include-v-in-tag": true, + "include-component-in-tag": false, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "extra-files": [ + { + "type": "generic", + "path": "Sources/ClamshellCore/BuildVersion.swift" + } + ], + "changelog-sections": [ + {"type": "feat", "section": "Features"}, + {"type": "fix", "section": "Fixes"}, + {"type": "perf", "section": "Performance"}, + {"type": "docs", "section": "Documentation"}, + {"type": "deps", "section": "Dependencies"}, + {"type": "test", "section": "Tests", "hidden": true}, + {"type": "build", "section": "Build", "hidden": true}, + {"type": "ci", "section": "CI", "hidden": true}, + {"type": "refactor", "section": "Refactoring", "hidden": true}, + {"type": "style", "section": "Style", "hidden": true}, + {"type": "chore", "section": "Chores", "hidden": true} + ] + } + } +} From 16efe31e82908e04fba3b8d5457e41d9b87542d7 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:32:10 +0100 Subject: [PATCH 18/47] docs(community): complete repository guidance --- .github/CODEOWNERS | 1 + .github/CODE_OF_CONDUCT.md | 83 +++++++++++++++++++++++++++ .github/CONTRIBUTING.md | 88 +++++++++++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 8 +++ .github/ISSUE_TEMPLATE/question.yml | 50 ++++++++++++++++ .github/SECURITY.md | 28 +++++++++ .github/SUPPORT.md | 9 +++ .github/pull_request_template.md | 26 +++++++++ README.md | 18 +++++- docs/clamshellctl-design.md | 31 +++++++--- docs/implementation-plan.md | 63 +++++++++------------ 11 files changed, 359 insertions(+), 46 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/CODE_OF_CONDUCT.md create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/question.yml create mode 100644 .github/SECURITY.md create mode 100644 .github/SUPPORT.md create mode 100644 .github/pull_request_template.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..3575c05 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @LMLiam diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..72dc1e1 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,83 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [https://github.com/LMLiam](https://github.com/LMLiam). All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..5ec5970 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,88 @@ +# Contributing + +## Prerequisites + +You need macOS, Xcode 26.6 or later, Swift 6.3 or later, and +[actionlint](https://github.com/rhysd/actionlint). Install actionlint with: + +```bash +brew install actionlint +``` + +The future companion app also requires +[XcodeGen](https://github.com/yonaskolb/XcodeGen). + +## Setup + +Fork the repository, clone your fork, and create a branch from `main`. Resolve +the package dependencies once after cloning: + +```bash +swift package resolve +``` + +Do not run tests against live privileged paths. Tests must use fake process and +filesystem boundaries; they must never change `pmset`, invoke `sudo`, or write +to `/Library/PrivilegedHelperTools` or `/etc/sudoers.d`. + +## Checks + +Run the complete local gate before each pull request: + +```bash +scripts/check.sh +``` + +The script checks formatting, SwiftLint, tests, debug and release builds, and +GitHub Actions workflows. When native app targets exist, it also generates and +builds the Xcode project without code signing. + +## Swift style + +Follow the [Google Swift Style Guide](https://google.github.io/swift/). +`swift-format` owns formatting; SwiftLint owns semantic and maintainability +rules. Prefer self-describing names. Add comments only for contracts, +constraints, or decisions that the code does not make clear. + +Keep one primary responsibility per file. A private helper may remain beside +its sole consumer. Do not create generic `Utils`, `Common`, or `Models` +directories. + +Fix lint findings in code where practical. A SwiftLint suppression must have a +narrow scope and an adjacent explanation of why the rule does not apply. + +## Commits + +Every commit and pull-request title must use: + +```text +verb(area): description +``` + +Examples include `feat(status): report battery state` and +`fix(setup): preserve an existing sudoers policy`. The accepted verbs are +`feat`, `fix`, `docs`, `test`, `build`, `ci`, `refactor`, `perf`, `style`, +`chore`, and `revert`. + +## Tests + +Test observable behaviour through public or internal boundaries. Cover failure +paths and privilege constraints. Avoid assertions about private implementation +details, mock call order unless order is part of the contract, and live system +mutation. + +## Pull requests + +Keep each pull request focused and link the issue it addresses. Describe any +change to command output, exit status, system paths, permissions, sudoers +policy, or the helper allow-list. Include the commands you ran and update user +documentation when behaviour changes. + +Maintainer approval and all required checks are needed before merge. The +repository uses squash merging, so the pull-request title becomes the release +commit subject. + +## Security + +Do not report vulnerabilities in a public issue. Follow the private reporting +instructions in [SECURITY.md](SECURITY.md). diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a99fc87 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Report a security vulnerability + url: https://github.com/LMLiam/clamshellctl/security/advisories/new + about: Send vulnerability details privately to the maintainer. + - name: Support and usage questions + url: https://github.com/LMLiam/clamshellctl/issues/new?template=question.yml + about: Ask about macOS, Homebrew, or clamshellctl usage. diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000..f5ea009 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,50 @@ +name: Support question +description: Ask about macOS, Homebrew, or clamshellctl usage. +title: "question(area): " +labels: + - question +body: + - type: markdown + attributes: + value: | + Do not include passwords, tokens, private keys, complete environment + dumps, or unrelated personal information. + - type: textarea + id: question + attributes: + label: Question + description: Explain what you are trying to do and where you are stuck. + validations: + required: true + - type: input + id: macos-version + attributes: + label: macOS version + placeholder: "26.6" + validations: + required: true + - type: dropdown + id: installation + attributes: + label: Installation method + options: + - Homebrew + - Companion-app DMG + - Source checkout + - Not installed + validations: + required: true + - type: textarea + id: diagnostics + attributes: + label: Safe diagnostic output + description: Include concise command output when it helps explain the question. + validations: + required: false + - type: checkboxes + id: safety + attributes: + label: Safety check + options: + - label: I removed passwords, tokens, private keys, and unrelated personal data. + required: true diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..80b67fc --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,28 @@ +# Security policy + +## Supported versions + +Only the latest published release receives security fixes. The project has not +published its first release yet. + +## Report a vulnerability + +Use [GitHub private vulnerability reporting](https://github.com/LMLiam/clamshellctl/security/advisories/new). +Do not open a public issue for a suspected vulnerability. + +Include the affected version, macOS version, impact, reproduction steps, and a +minimal proof when safe. Do not include live credentials, secrets, personal +data, or destructive commands. We will acknowledge a report within seven days +and keep you informed while we validate and fix it. + +## Security-sensitive surfaces + +Changes to the root-owned helper, sudoers policy, installation paths, ownership +or permissions, process execution, and unsigned app distribution require extra +review. The helper must continue to accept only the exact `enable` and +`disable` actions. The sudoers policy must not grant password-free access to +the public CLI, `pmset`, a shell, or a user-writable executable. + +The planned companion app and DMG will use ad-hoc signing without Apple +notarisation. Release documentation must state that boundary and provide the +specific Gatekeeper approval steps without implying Apple review. diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md new file mode 100644 index 0000000..943b440 --- /dev/null +++ b/.github/SUPPORT.md @@ -0,0 +1,9 @@ +# Support + +- Report reproducible defects with the [bug form](https://github.com/LMLiam/clamshellctl/issues/new?template=bug.yml). +- Propose a change with the [feature form](https://github.com/LMLiam/clamshellctl/issues/new?template=feature.yml). +- Report vulnerabilities through [private vulnerability reporting](https://github.com/LMLiam/clamshellctl/security/advisories/new). +- Ask macOS, Homebrew, or usage questions with the [support form](https://github.com/LMLiam/clamshellctl/issues/new?template=question.yml). + +This is a community project. Opening an issue does not guarantee individual +support or a response time. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..877e521 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,26 @@ +## Summary + + + +## Issues + + + +## Behaviour and security + + + +## Verification + + + +## Screenshots + + + +## Checklist + +- [ ] `scripts/check.sh` passes. +- [ ] User and contributor documentation matches the change. +- [ ] Every commit uses `verb(area): description`. +- [ ] Privileged behaviour is covered by safe tests rather than live system mutation. diff --git a/README.md b/README.md index 7d2af8c..8543f80 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,9 @@ # clamshellctl +[![CI](https://github.com/LMLiam/clamshellctl/actions/workflows/ci.yml/badge.svg)](https://github.com/LMLiam/clamshellctl/actions/workflows/ci.yml) +[![CodeQL](https://github.com/LMLiam/clamshellctl/actions/workflows/codeql.yml/badge.svg)](https://github.com/LMLiam/clamshellctl/actions/workflows/codeql.yml) + Control battery clamshell mode on macOS. > [!IMPORTANT] @@ -55,10 +58,21 @@ Requirements: Run the current checks with: ```bash -swift test -swift build -c release +scripts/check.sh ``` +See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for setup, style, testing, and +commit conventions. + +## Community + +- Use the [issue forms](https://github.com/LMLiam/clamshellctl/issues/new/choose) + for bugs and feature proposals. +- Read [SUPPORT.md](.github/SUPPORT.md) before asking a usage question. +- Report vulnerabilities through the private process in + [SECURITY.md](.github/SECURITY.md). +- Follow the [Code of Conduct](.github/CODE_OF_CONDUCT.md) when participating. + ## Licence `clamshellctl` is available under the [MIT License](LICENSE). diff --git a/docs/clamshellctl-design.md b/docs/clamshellctl-design.md index a1f42e0..229c6bb 100644 --- a/docs/clamshellctl-design.md +++ b/docs/clamshellctl-design.md @@ -160,30 +160,45 @@ Diagnostic command output is included only when it is safe and useful. No operat clamshellctl/ ├── .github/ │ ├── ISSUE_TEMPLATE/ -│ └── workflows/ +│ ├── workflows/ +│ ├── CODEOWNERS +│ ├── CONTRIBUTING.md +│ ├── SECURITY.md +│ └── SUPPORT.md ├── Sources/ │ ├── ClamshellCore/ +│ │ ├── Errors/ +│ │ ├── Power/ +│ │ ├── Privilege/Installation/ +│ │ ├── Process/ +│ │ ├── State/ +│ │ └── Timing/ # Added with timed enablement │ ├── ClamshellCLI/ +│ │ └── Commands/ │ └── ClamshellHelper/ ├── App/ │ ├── ClamshellApp/ │ └── ClamshellControl/ -├── project.yml ├── Tests/ -│ └── ClamshellCoreTests/ +│ ├── ClamshellCoreTests/ +│ │ ├── Power/ +│ │ ├── Privilege/Installation/ +│ │ ├── Process/ +│ │ ├── State/ +│ │ └── Support/ +│ └── ClamshellCLITests/Commands/ ├── docs/ │ ├── assets/ │ └── clamshellctl-design.md +├── scripts/ ├── .release-please-manifest.json ├── CHANGELOG.md -├── CONTRIBUTING.md ├── LICENSE ├── Package.swift ├── README.md -├── SECURITY.md -├── SUPPORT.md ├── release-please-config.json -└── version.txt +├── version.txt +└── project.yml # Added with the native companion ``` The approved transparent artwork is stored as `docs/assets/clamshellctl.png` and used near the top of the README. @@ -260,7 +275,7 @@ The initial public setup includes: Initial issues cover the core CLI, privileged setup, timed enablement, native companion feasibility, Control Centre integration, DMG packaging, documentation, Homebrew publication, and release automation. A later issue covers USB-C disconnect automation. -After the repository is public and its README is ready, `LMLiam/LMLiam` is updated to feature `clamshellctl` on the GitHub profile. +After the first public release succeeds, `LMLiam/LMLiam` is updated to feature `clamshellctl` on the GitHub profile. ## Success criteria diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index e2a1ff8..29d6f07 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -16,21 +16,19 @@ The implementation uses responsibility-based files rather than grouping unrelate ```text Package.swift SwiftPM products, targets, dependency versions -Sources/ClamshellCore/BuildVersion.swift Release-please managed version -Sources/ClamshellCore/ClamshellState.swift Enabled and disabled domain states -Sources/ClamshellCore/PowerSettingsParser.swift Battery-section pmset parsing -Sources/ClamshellCore/ProcessRunner.swift Process boundary and Foundation adapter -Sources/ClamshellCore/PowerSettingsClient.swift Read and mutate pmset through ProcessRunner -Sources/ClamshellCore/ClamshellService.swift Idempotent state transitions -Sources/ClamshellCore/Duration.swift Strict m, h, and d duration parsing -Sources/ClamshellCore/TimerMetadata.swift Codable absolute timer deadline -Sources/ClamshellCore/TimerController.swift LaunchAgent lifecycle and expiry decisions -Sources/ClamshellCore/PrivilegedInstallation.swift Root setup and uninstall rules -Sources/ClamshellCore/SudoersPolicy.swift Exact sudoers document generation -Sources/ClamshellCore/ClamshellError.swift Domain errors and exit-code mapping +Sources/ClamshellCore/BuildVersion.swift Release-please-managed version +Sources/ClamshellCore/Errors/*.swift Domain errors and user-facing recovery +Sources/ClamshellCore/Power/*.swift pmset parsing, reads, writes, and helper arguments +Sources/ClamshellCore/Privilege/*.swift Helper client, paths, and sudoers policy +Sources/ClamshellCore/Privilege/Installation/*.swift Root setup, verification, and removal +Sources/ClamshellCore/Process/*.swift Process values, protocol, and Foundation adapter +Sources/ClamshellCore/State/*.swift State contracts and idempotent transitions +Sources/ClamshellCore/Timing/*.swift Duration and LaunchAgent lifecycle added in Phase 4 Sources/ClamshellCLI/ClamshellCommand.swift Root ArgumentParser command and composition root Sources/ClamshellCLI/Commands/*.swift One public command per file +Sources/ClamshellCLI/CommandComposition.swift Production dependency composition Sources/ClamshellCLI/Console.swift Quiet-aware stdout and stderr rendering +Sources/ClamshellCLI/OutputOptions.swift Shared command output flags Sources/ClamshellHelper/ClamshellHelper.swift Minimal privileged executable entry point App/ClamshellApp/ClamshellApp.swift Setup-only SwiftUI application entry point App/ClamshellApp/SetupView.swift First-run, diagnostics, and removal interface @@ -42,14 +40,16 @@ App/ClamshellControl/SetBatteryClamshellIntent.swift Exact-state App Intent acti App/ClamshellControl/Info.plist WidgetKit extension metadata App/ClamshellAppTests/*.swift Companion model tests App/ClamshellControlTests/*.swift Control model tests -Tests/ClamshellCoreTests/*.swift Swift Testing suites by responsibility -Tests/ClamshellCLITests/*.swift Black-box CLI tests without privilege changes +Tests/ClamshellCoreTests/{Power,Privilege,Process,State}/*.swift Domain suites +Tests/ClamshellCoreTests/Support/*.swift Shared test support by responsibility +Tests/ClamshellCLITests/Commands/*.swift Black-box CLI tests without privilege changes project.yml Deterministic XcodeGen app project definition scripts/embed-command-products.sh Bundle release CLI and helper payloads scripts/package-dmg.sh Build the ad-hoc-signed release DMG Tests/Scripts/run-dmg-packaging-tests.sh DMG structure and input validation tests scripts/generate-homebrew-formula.sh Deterministic formula generation -scripts/validate-conventional-title.sh Pull-request title validation +scripts/check-conventional-subject.sh Commit and pull-request title validation +scripts/check.sh Complete local quality gate .github/workflows/ci.yml macOS build and test checks .github/workflows/pr.yml Conventional pull-request title check .github/workflows/release.yml release-please and tap publication @@ -59,9 +59,9 @@ version.txt Simple release strategy versi Formula/clamshellctl.rb Generated formula fixture for validation docs/assets/clamshellctl.png Approved transparent README artwork README.md Installation, safety, use, and troubleshooting -CONTRIBUTING.md Development and commit conventions -SECURITY.md Privilege boundary and reporting policy -SUPPORT.md Support channels and diagnostics +.github/CONTRIBUTING.md Development and commit conventions +.github/SECURITY.md Privilege boundary and reporting policy +.github/SUPPORT.md Support channels and diagnostics LICENSE MIT licence ``` @@ -1081,9 +1081,9 @@ git commit -m "build(dmg): package self-contained companion" **Files:** - Modify: `README.md` -- Create: `CONTRIBUTING.md` -- Create: `SECURITY.md` -- Create: `SUPPORT.md` +- Create: `.github/CONTRIBUTING.md` +- Create: `.github/SECURITY.md` +- Create: `.github/SUPPORT.md` - Create: `.markdownlint-cli2.jsonc` - [ ] **Step 1: Write the complete README** @@ -1092,18 +1092,18 @@ Cover purpose, warning and scope, separate Homebrew and DMG installation paths, - [ ] **Step 2: Write maintenance policies** -`CONTRIBUTING.md` requires Swift 6, `swift format`, `swift test`, Conventional Commits, and `verb(area): description`. `SECURITY.md` explains the helper and sudoers boundary and provides private reporting instructions. `SUPPORT.md` lists safe diagnostic commands and forbids posting sudoers contents containing unexpected local customisations without review. Configure markdownlint with `MD013` disabled so prose uses semantic lines without an arbitrary rendered-width limit; keep all other default rules enabled. +`CONTRIBUTING.md` requires Swift 6, `scripts/check.sh`, Conventional Commits, and `verb(area): description`. `SECURITY.md` explains the helper and sudoers boundary and provides private reporting instructions. `SUPPORT.md` lists safe diagnostic commands and forbids posting sudoers contents containing unexpected local customisations without review. Configure markdownlint with `MD013` disabled so prose uses semantic lines without an arbitrary rendered-width limit; keep all other default rules enabled. - [ ] **Step 3: Check links and prose** -Run: `npx --yes markdownlint-cli2 '**/*.md' '#.build'` +Run: `scripts/check.sh && npx --yes markdownlint-cli2 '**/*.md' '#.build'` Expected: no Markdown errors. Manually verify every relative link and ensure no internal tooling paths or process notes exist anywhere in the repository. - [ ] **Step 4: Commit documentation** ```bash -git add .markdownlint-cli2.jsonc README.md CONTRIBUTING.md SECURITY.md SUPPORT.md docs +git add .markdownlint-cli2.jsonc README.md .github docs git commit -m "docs(project): document installation and maintenance" ``` @@ -1128,7 +1128,7 @@ Use a portable anchored regular expression for the allowed Conventional Commit v - [ ] **Step 3: Add least-privilege workflows** -`ci.yml` runs on pushes to `main` and pull requests, uses a macOS runner, checks out pinned action SHAs, installs XcodeGen, runs `swift package resolve`, `swift format lint --recursive --strict .`, `swift test`, `swift build -c release`, `xcodegen generate`, and an ad-hoc-signed `xcodebuild` for `ClamshellApp`. It grants `contents: read` only. +`ci.yml` runs on pushes to `main` and pull requests, uses macOS runners, checks out pinned action SHAs, and runs formatting, SwiftLint, tests, and debug and release builds. Add XcodeGen and an unsigned `xcodebuild` when the native targets exist. It grants `contents: read` only. `pr.yml` validates the pull-request title without checking out or executing pull-request code. It grants `pull-requests: read` only. @@ -1138,11 +1138,7 @@ Run: ```bash bash Tests/Scripts/run-title-tests.sh -swift format lint --recursive --strict . -swift test -xcodegen generate -xcodebuild -project Clamshell.xcodeproj -scheme ClamshellApp -configuration Debug -destination 'platform=macOS' CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= build -actionlint +scripts/check.sh ``` Install `actionlint` with `brew install actionlint` first when it is not already available. Expected: all local checks pass. @@ -1341,12 +1337,7 @@ Close the launch milestone only after the GitHub Release, public DMG, Homebrew i Before declaring the MVP complete, run: ```bash -swift format lint --recursive --strict . -swift test -swift build -c release -xcodegen generate -xcodebuild -project Clamshell.xcodeproj -scheme ClamshellApp -configuration Release -destination 'platform=macOS' CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= test -actionlint +scripts/check.sh bash scripts/check-version-consistency.sh bash Tests/Scripts/run-title-tests.sh bash Tests/Scripts/run-dmg-packaging-tests.sh From a8656668c15078add9bce74b509095ec7454ee4d Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:55:09 +0100 Subject: [PATCH 19/47] test(safety): isolate privileged command checks --- .../ClamshellCLI/Commands/SetupCommand.swift | 7 ++- .../Commands/UninstallCommand.swift | 7 ++- .../Commands/SetupCommandTests.swift | 52 +++++++++++++++++-- .../BuildVersionTests.swift | 12 +++-- .../PrivilegedInstallationFailureTests.swift | 46 ++++++++++++++++ .../Support/InstallationTestSupport.swift | 10 +++- 6 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationFailureTests.swift diff --git a/Sources/ClamshellCLI/Commands/SetupCommand.swift b/Sources/ClamshellCLI/Commands/SetupCommand.swift index 3c02f3d..160af00 100644 --- a/Sources/ClamshellCLI/Commands/SetupCommand.swift +++ b/Sources/ClamshellCLI/Commands/SetupCommand.swift @@ -1,4 +1,5 @@ import ArgumentParser +import ClamshellCore struct SetupCommand: ParsableCommand { static let configuration = CommandConfiguration( @@ -9,7 +10,11 @@ struct SetupCommand: ParsableCommand { @OptionGroup var output: OutputOptions func run() throws { - let result = try CommandComposition.privilegedInstallation().install() + try run(installation: CommandComposition.privilegedInstallation()) + } + + func run(installation: PrivilegedInstallation) throws { + let result = try installation.install() let console = Console(isQuiet: output.quiet) if result.didChange { diff --git a/Sources/ClamshellCLI/Commands/UninstallCommand.swift b/Sources/ClamshellCLI/Commands/UninstallCommand.swift index 1b141ce..9f451a0 100644 --- a/Sources/ClamshellCLI/Commands/UninstallCommand.swift +++ b/Sources/ClamshellCLI/Commands/UninstallCommand.swift @@ -1,4 +1,5 @@ import ArgumentParser +import ClamshellCore struct UninstallCommand: ParsableCommand { static let configuration = CommandConfiguration( @@ -9,7 +10,11 @@ struct UninstallCommand: ParsableCommand { @OptionGroup var output: OutputOptions func run() throws { - let result = try CommandComposition.privilegedInstallation().uninstall() + try run(installation: CommandComposition.privilegedInstallation()) + } + + func run(installation: PrivilegedInstallation) throws { + let result = try installation.uninstall() let console = Console(isQuiet: output.quiet) guard !result.removedPaths.isEmpty else { diff --git a/Tests/ClamshellCLITests/Commands/SetupCommandTests.swift b/Tests/ClamshellCLITests/Commands/SetupCommandTests.swift index e9b6b83..481835d 100644 --- a/Tests/ClamshellCLITests/Commands/SetupCommandTests.swift +++ b/Tests/ClamshellCLITests/Commands/SetupCommandTests.swift @@ -8,27 +8,71 @@ import Testing struct SetupCommandTests { @Test("setup requires an explicit administrator invocation") func setupRequiresRoot() throws { - var command = try ClamshellCommand.parseAsRoot(["setup"]) + let command = try #require(try ClamshellCommand.parseAsRoot(["setup"]) as? SetupCommand) #expect( throws: ClamshellError.administratorPrivilegesRequired( command: PrivilegedHelperClient.setupCommand ) ) { - try command.run() + try command.run(installation: inertInstallation()) } } @Test("uninstall requires an explicit administrator invocation") func uninstallRequiresRoot() throws { - var command = try ClamshellCommand.parseAsRoot(["uninstall"]) + let command = try #require( + try ClamshellCommand.parseAsRoot(["uninstall"]) as? UninstallCommand + ) #expect( throws: ClamshellError.administratorPrivilegesRequired( command: PrivilegedHelperClient.uninstallCommand ) ) { - try command.run() + try command.run(installation: inertInstallation()) } } + + private func inertInstallation() -> PrivilegedInstallation { + PrivilegedInstallation( + fileSystem: InertInstallationFileSystem(), + runner: InertProcessRunner(), + effectiveUserID: 501, + environment: [:], + executablePath: "/unused" + ) + } + + private struct InertInstallationFileSystem: InstallationFileSystem { + func itemExists(at path: String) -> Bool { false } + func isRegularFile(at path: String) -> Bool { false } + func contentsEqual(at firstPath: String, and secondPath: String) -> Bool { false } + func readText(at path: String) throws -> String { throw UnexpectedOperation() } + func attributes(at path: String) throws -> InstalledFileAttributes { + throw UnexpectedOperation() + } + func copyItem(at source: String, to destination: String) throws { + throw UnexpectedOperation() + } + func write(_ contents: String, to path: String) throws { throw UnexpectedOperation() } + func setOwner(userID: UInt32, groupID: UInt32, at path: String) throws { + throw UnexpectedOperation() + } + func setPermissions(_ permissions: UInt16, at path: String) throws { + throw UnexpectedOperation() + } + func replaceItem(at destination: String, withItemAt replacement: String) throws { + throw UnexpectedOperation() + } + func removeItem(at path: String) throws { throw UnexpectedOperation() } + } + + private struct InertProcessRunner: ProcessRunning { + func run(_ executable: String, arguments: [String]) throws -> ProcessResult { + throw UnexpectedOperation() + } + } + + private struct UnexpectedOperation: Error {} } diff --git a/Tests/ClamshellCoreTests/BuildVersionTests.swift b/Tests/ClamshellCoreTests/BuildVersionTests.swift index 2138b2f..0b43ab8 100644 --- a/Tests/ClamshellCoreTests/BuildVersionTests.swift +++ b/Tests/ClamshellCoreTests/BuildVersionTests.swift @@ -4,8 +4,14 @@ import Testing @Suite("Build version") struct BuildVersionTests { - @Test("starts at the planned initial version") - func initialVersion() { - #expect(BuildVersion.current == "0.1.0") + @Test("uses a three-component semantic version") + func semanticVersion() { + let components = BuildVersion.current.split( + separator: ".", + omittingEmptySubsequences: false + ) + + #expect(components.count == 3) + #expect(components.allSatisfy { UInt($0) != nil }) } } diff --git a/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationFailureTests.swift b/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationFailureTests.swift new file mode 100644 index 0000000..3023238 --- /dev/null +++ b/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationFailureTests.swift @@ -0,0 +1,46 @@ +import Testing + +@testable import ClamshellCore + +@Suite("Privileged installation failures") +struct PrivilegedInstallationFailureTests { + @Test("rejects installation when the helper payload is missing") + func missingHelperPayload() { + let log = InstallationOperationLog() + let installation = PrivilegedInstallation( + fileSystem: RecordingInstallationFileSystem(files: [:], log: log), + runner: InstallationRecordingRunner(result: .success, log: log), + effectiveUserID: 0, + environment: ["SUDO_USER": "liam"], + executablePath: "/tmp/build/clamshellctl", + temporarySuffix: "test" + ) + + #expect(throws: ClamshellError.helperPayloadNotFound) { + try installation.install() + } + } + + @Test("rejects an installation that does not verify") + func verificationFailure() { + let log = InstallationOperationLog() + let source = "/tmp/build/clamshellctl-helper" + let fileSystem = RecordingInstallationFileSystem( + files: [source: "helper payload"], + log: log, + reportsMatchingContents: false + ) + let installation = PrivilegedInstallation( + fileSystem: fileSystem, + runner: InstallationRecordingRunner(result: .success, log: log), + effectiveUserID: 0, + environment: ["SUDO_USER": "liam"], + executablePath: "/tmp/build/clamshellctl", + temporarySuffix: "test" + ) + + #expect(throws: ClamshellError.installationVerificationFailed) { + try installation.install() + } + } +} diff --git a/Tests/ClamshellCoreTests/Support/InstallationTestSupport.swift b/Tests/ClamshellCoreTests/Support/InstallationTestSupport.swift index 380f288..421726c 100644 --- a/Tests/ClamshellCoreTests/Support/InstallationTestSupport.swift +++ b/Tests/ClamshellCoreTests/Support/InstallationTestSupport.swift @@ -38,10 +38,15 @@ final class RecordingInstallationFileSystem: { private let lock = NSLock() private let log: InstallationOperationLog + private let reportsMatchingContents: Bool private var files: [String: String] private var fileAttributes: [String: InstalledFileAttributes] - init(files: [String: String], log: InstallationOperationLog) { + init( + files: [String: String], + log: InstallationOperationLog, + reportsMatchingContents: Bool = true + ) { self.files = files fileAttributes = Dictionary( uniqueKeysWithValues: files.keys.map { @@ -49,6 +54,7 @@ final class RecordingInstallationFileSystem: } ) self.log = log + self.reportsMatchingContents = reportsMatchingContents } func itemExists(at path: String) -> Bool { @@ -63,7 +69,7 @@ final class RecordingInstallationFileSystem: func contentsEqual(at firstPath: String, and secondPath: String) -> Bool { log.append(.contentsEqual(firstPath: firstPath, secondPath: secondPath)) - return lock.withLock { files[firstPath] == files[secondPath] } + return reportsMatchingContents && lock.withLock { files[firstPath] == files[secondPath] } } func readText(at path: String) throws -> String { From 3c9dbb5a3e47c951bbb62e4037e500c8a5edc0ce Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:55:15 +0100 Subject: [PATCH 20/47] ci(release): restrict manual releases to main --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b7ec825..d5585b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,7 @@ permissions: {} jobs: release-please: name: Release Please + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 10 permissions: From 9ce4d0b6c7300ab41c75865ed741f5d3d9c0dc68 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:55:19 +0100 Subject: [PATCH 21/47] build(lint): remove inactive analyzer rule --- .swiftlint.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index afd9986..b24d445 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -48,9 +48,6 @@ opt_in_rules: - toggle_bool - unavailable_function -analyzer_rules: - - unused_import - closure_body_length: warning: 40 error: 60 From 335e75a7bedc3a4e12c7e34fb4cf44ecbd59a8b1 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:55:26 +0100 Subject: [PATCH 22/47] docs(quality): align repository guidance --- .github/SUPPORT.md | 5 +++++ docs/implementation-plan.md | 12 +++++++----- docs/repository-quality-design.md | 2 +- .../repository-quality-implementation-plan.md | 19 ++++++------------- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md index 943b440..843bae3 100644 --- a/.github/SUPPORT.md +++ b/.github/SUPPORT.md @@ -7,3 +7,8 @@ This is a community project. Opening an issue does not guarantee individual support or a response time. + +Safe diagnostics include `clamshellctl --version` and `clamshellctl status`. +Review output before posting it. Do not publish sudoers contents when they +contain unexpected local changes; use private vulnerability reporting if those +changes may expose a security problem. diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 29d6f07..966903d 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -1115,8 +1115,7 @@ git commit -m "docs(project): document installation and maintenance" - Create: `.github/workflows/ci.yml` - Create: `.github/workflows/pr.yml` -- Create: `scripts/validate-conventional-title.sh` -- Create: `Tests/Scripts/run-title-tests.sh` +- Create: `scripts/check-conventional-subject.sh` - [ ] **Step 1: Test the title validator** @@ -1137,7 +1136,8 @@ Use a portable anchored regular expression for the allowed Conventional Commit v Run: ```bash -bash Tests/Scripts/run-title-tests.sh +scripts/check-conventional-subject.sh "feat(cli): add toggle command" +! scripts/check-conventional-subject.sh "feat: add toggle command" scripts/check.sh ``` @@ -1146,7 +1146,7 @@ Install `actionlint` with `brew install actionlint` first when it is not already - [ ] **Step 5: Commit CI** ```bash -git add .github/workflows scripts Tests/Scripts +git add .github/workflows scripts git commit -m "ci(checks): verify Swift and pull-request quality" ``` @@ -1339,7 +1339,9 @@ Before declaring the MVP complete, run: ```bash scripts/check.sh bash scripts/check-version-consistency.sh -bash Tests/Scripts/run-title-tests.sh +while IFS= read -r subject; do + scripts/check-conventional-subject.sh "$subject" +done < <(git log --format=%s origin/main..HEAD) bash Tests/Scripts/run-dmg-packaging-tests.sh bash Tests/Scripts/run-formula-generator-tests.sh bash Tests/Scripts/run-publish-formula-tests.sh diff --git a/docs/repository-quality-design.md b/docs/repository-quality-design.md index 99f2f12..2f9279c 100644 --- a/docs/repository-quality-design.md +++ b/docs/repository-quality-design.md @@ -185,7 +185,7 @@ publishes results through GitHub code scanning. ### Pull-request titles -`pr-title.yml` enforces `verb(area): description`. Accepted verbs follow the +`pr.yml` enforces `verb(area): description`. Accepted verbs follow the repository's release-please conventions. The same structure is used for local commits. diff --git a/docs/repository-quality-implementation-plan.md b/docs/repository-quality-implementation-plan.md index ec69fb9..e8ded47 100644 --- a/docs/repository-quality-implementation-plan.md +++ b/docs/repository-quality-implementation-plan.md @@ -1,9 +1,5 @@ # Repository Quality Implementation Plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use -> `superpowers:executing-plans` to implement this plan task by task. Liam has -> explicitly prohibited subagents for this work. - **Goal:** Make `clamshellctl` an idiomatic, consistently enforced Swift project with a complete public GitHub repository, CodeQL analysis, release automation, and protected-main governance. @@ -565,9 +561,6 @@ opt_in_rules: - toggle_bool - unavailable_function -analyzer_rules: - - unused_import - closure_body_length: warning: 40 error: 60 @@ -599,9 +592,9 @@ Run: swift package plugin --allow-writing-to-package-directory swiftlint --strict ``` -Expected: non-zero until every reported correctness, naming, complexity, and -unused-code violation is addressed. Do not create a baseline or disable a rule -for the whole repository. +Expected: non-zero until every reported correctness, naming, and complexity +violation is addressed. Do not create a baseline or disable a rule for the +whole repository. - [ ] **Step 4: Document non-obvious public contracts** @@ -1110,9 +1103,9 @@ security reporting. It must state that SwiftLint suppressions require a local reason and that contributors must not run privileged tests against live system paths. -Use Contributor Covenant 2.1 unchanged in `CODE_OF_CONDUCT.md`, with -`https://github.com/LMLiam` as the enforcement contact rather than a personal -email address. +Use Contributor Covenant 2.1 unchanged except for replacing its enforcement +contact placeholder with `https://github.com/LMLiam`. This intentional +substitution avoids publishing a personal email address. - [ ] **Step 2: Add security and support policy** From bb62e62039345cddac8c056c5313ced9f537b2c9 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:10:11 +0100 Subject: [PATCH 23/47] fix(privilege): stage installation files before commit --- .../Installation/PrivilegedInstallation.swift | 63 ++++++++++++++----- .../PrivilegedInstallationTests.swift | 4 +- 2 files changed, 50 insertions(+), 17 deletions(-) diff --git a/Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift b/Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift index 3d1f126..9911a01 100644 --- a/Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift +++ b/Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift @@ -46,25 +46,37 @@ public struct PrivilegedInstallation: Sendable { let payload = try helperPayloadPath() if try isConfigured(payload: payload, policy: policy) { - return InstallationResult( - helperPath: PrivilegedPaths.helper, - sudoersPolicyPath: PrivilegedPaths.sudoersPolicy, - didChange: false - ) + return installationResult(didChange: false) } - try installHelper(payload: payload) - try installPolicy(policy) + let helperTemporary = try stageHelper(payload: payload) + var helperTemporaryExists = true + defer { + if helperTemporaryExists { + try? fileSystem.removeItem(at: helperTemporary) + } + } + + let policyTemporary = try stagePolicy(policy) + var policyTemporaryExists = true + defer { + if policyTemporaryExists { + try? fileSystem.removeItem(at: policyTemporary) + } + } + + try commit( + helperTemporary: helperTemporary, + policyTemporary: policyTemporary + ) + policyTemporaryExists = false + helperTemporaryExists = false guard try isConfigured(payload: payload, policy: policy) else { throw ClamshellError.installationVerificationFailed } - return InstallationResult( - helperPath: PrivilegedPaths.helper, - sudoersPolicyPath: PrivilegedPaths.sudoersPolicy, - didChange: true - ) + return installationResult(didChange: true) } /// Removes only the two managed installation paths and is safe to repeat. @@ -84,7 +96,7 @@ public struct PrivilegedInstallation: Sendable { try requireAdministratorPrivileges(command: PrivilegedHelperClient.setupCommand) } - private func installHelper(payload: String) throws { + private func stageHelper(payload: String) throws -> String { let temporary = temporaryPath(for: PrivilegedPaths.helper) var temporaryExists = false defer { @@ -97,11 +109,11 @@ public struct PrivilegedInstallation: Sendable { temporaryExists = true try fileSystem.setOwner(userID: 0, groupID: 0, at: temporary) try fileSystem.setPermissions(0o755, at: temporary) - try fileSystem.replaceItem(at: PrivilegedPaths.helper, withItemAt: temporary) temporaryExists = false + return temporary } - private func installPolicy(_ policy: SudoersPolicy) throws { + private func stagePolicy(_ policy: SudoersPolicy) throws -> String { let temporary = temporaryPath(for: PrivilegedPaths.sudoersPolicy) var temporaryExists = false defer { @@ -120,8 +132,27 @@ public struct PrivilegedInstallation: Sendable { throw ClamshellError.sudoersValidationFailed } - try fileSystem.replaceItem(at: PrivilegedPaths.sudoersPolicy, withItemAt: temporary) temporaryExists = false + return temporary + } + + private func commit(helperTemporary: String, policyTemporary: String) throws { + try fileSystem.replaceItem( + at: PrivilegedPaths.sudoersPolicy, + withItemAt: policyTemporary + ) + try fileSystem.replaceItem( + at: PrivilegedPaths.helper, + withItemAt: helperTemporary + ) + } + + private func installationResult(didChange: Bool) -> InstallationResult { + InstallationResult( + helperPath: PrivilegedPaths.helper, + sudoersPolicyPath: PrivilegedPaths.sudoersPolicy, + didChange: didChange + ) } private func requireAdministratorPrivileges(command: String) throws { diff --git a/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationTests.swift b/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationTests.swift index df6ca99..7df88e4 100644 --- a/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationTests.swift +++ b/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationTests.swift @@ -163,6 +163,7 @@ struct PrivilegedInstallationTests { let fileSystem = RecordingInstallationFileSystem( files: [ source: "helper payload", + PrivilegedPaths.helper: "existing helper", PrivilegedPaths.sudoersPolicy: "existing policy", ], log: log @@ -187,6 +188,7 @@ struct PrivilegedInstallationTests { #expect(throws: ClamshellError.sudoersValidationFailed) { try installation.install() } + #expect(fileSystem.contents(at: PrivilegedPaths.helper) == "existing helper") #expect(fileSystem.contents(at: PrivilegedPaths.sudoersPolicy) == "existing policy") #expect( !log.operations.contains( @@ -239,7 +241,6 @@ struct PrivilegedInstallationTests { .copy(source: source, destination: helperTemporary), .setOwner(path: helperTemporary, userID: 0, groupID: 0), .setPermissions(path: helperTemporary, permissions: 0o755), - .replace(replacement: helperTemporary, destination: PrivilegedPaths.helper), .write(contents: policyContents, path: policyTemporary), .setOwner(path: policyTemporary, userID: 0, groupID: 0), .setPermissions(path: policyTemporary, permissions: 0o440), @@ -248,6 +249,7 @@ struct PrivilegedInstallationTests { replacement: policyTemporary, destination: PrivilegedPaths.sudoersPolicy ), + .replace(replacement: helperTemporary, destination: PrivilegedPaths.helper), .isRegularFile(PrivilegedPaths.helper), .contentsEqual(firstPath: source, secondPath: PrivilegedPaths.helper), .attributes(PrivilegedPaths.helper), From 32636f6da052ceee666276c24569b99453f6e222 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:10:15 +0100 Subject: [PATCH 24/47] fix(privilege): reject the sudoers ALL alias --- Sources/ClamshellCore/Privilege/SudoersPolicy.swift | 8 ++++++-- .../ClamshellCoreTests/Privilege/SudoersPolicyTests.swift | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/ClamshellCore/Privilege/SudoersPolicy.swift b/Sources/ClamshellCore/Privilege/SudoersPolicy.swift index cdc0f40..c660296 100644 --- a/Sources/ClamshellCore/Privilege/SudoersPolicy.swift +++ b/Sources/ClamshellCore/Privilege/SudoersPolicy.swift @@ -1,9 +1,13 @@ public struct SudoersPolicy: Sendable, Equatable { public let username: String - /// Rejects root, empty names, and characters that could alter sudoers syntax. + /// Rejects root, the sudoers `ALL` alias, empty names, and unsafe characters. public init(username: String) throws { - guard username != "root", !username.isEmpty, username.unicodeScalars.allSatisfy(Self.isSafe) + guard + username != "root", + username != "ALL", + !username.isEmpty, + username.unicodeScalars.allSatisfy(Self.isSafe) else { throw ClamshellError.invalidUsername(username) } diff --git a/Tests/ClamshellCoreTests/Privilege/SudoersPolicyTests.swift b/Tests/ClamshellCoreTests/Privilege/SudoersPolicyTests.swift index 2cc5230..c46772c 100644 --- a/Tests/ClamshellCoreTests/Privilege/SudoersPolicyTests.swift +++ b/Tests/ClamshellCoreTests/Privilege/SudoersPolicyTests.swift @@ -31,6 +31,7 @@ struct SudoersPolicyTests { let invalidUsernames = [ "", "root", + "ALL", "liam smith", "../liam", "liam/name", From e73e6a91c14e8015cebb1e1dee546b50b1ebf26b Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:10:19 +0100 Subject: [PATCH 25/47] fix(cli): make status output unconditional --- Sources/ClamshellCLI/Commands/StatusCommand.swift | 4 +--- .../Commands/StatusCommandTests.swift | 13 +++++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 Tests/ClamshellCLITests/Commands/StatusCommandTests.swift diff --git a/Sources/ClamshellCLI/Commands/StatusCommand.swift b/Sources/ClamshellCLI/Commands/StatusCommand.swift index 58f7342..5806d52 100644 --- a/Sources/ClamshellCLI/Commands/StatusCommand.swift +++ b/Sources/ClamshellCLI/Commands/StatusCommand.swift @@ -7,12 +7,10 @@ struct StatusCommand: ParsableCommand { abstract: "Show the current battery clamshell mode." ) - @OptionGroup var output: OutputOptions - func run() throws { let client = PowerSettingsClient(runner: FoundationProcessRunner()) let state = try client.currentState() - Console(isQuiet: output.quiet).writeLine( + Console(isQuiet: false).writeLine( "Battery clamshell mode: \(state.rawValue)" ) } diff --git a/Tests/ClamshellCLITests/Commands/StatusCommandTests.swift b/Tests/ClamshellCLITests/Commands/StatusCommandTests.swift new file mode 100644 index 0000000..63e1da7 --- /dev/null +++ b/Tests/ClamshellCLITests/Commands/StatusCommandTests.swift @@ -0,0 +1,13 @@ +import Testing + +@testable import ClamshellCLI + +@Suite("Status commands") +struct StatusCommandTests { + @Test("status rejects quiet output") + func quietOutputIsRejected() { + #expect(throws: Error.self) { + _ = try ClamshellCommand.parseAsRoot(["status", "--quiet"]) + } + } +} From 64d44f3539352cfeb0ce3b41b574bd24637996d0 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:10:23 +0100 Subject: [PATCH 26/47] build(package): declare the CLI test dependency --- Package.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index e5a49c9..d742eda 100644 --- a/Package.swift +++ b/Package.swift @@ -35,7 +35,11 @@ let package = Package( .testTarget(name: "ClamshellCoreTests", dependencies: ["ClamshellCore"]), .testTarget( name: "ClamshellCLITests", - dependencies: ["ClamshellCLI", "ClamshellCore"] + dependencies: [ + "ClamshellCLI", + "ClamshellCore", + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ] ), ] ) From f134e77bb2c84ef76a5840108421c4b2f675c642 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:10:31 +0100 Subject: [PATCH 27/47] ci(checks): harden workflow validation --- .github/workflows/codeql.yml | 5 ++++- .github/workflows/pr.yml | 10 +++++++++- .github/workflows/release.yml | 1 + 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b393b5c..89835dc 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -15,13 +15,16 @@ concurrency: permissions: contents: read - security-events: write jobs: analyze: name: Analyze (swift) runs-on: macos-26 timeout-minutes: 30 + permissions: + contents: read + # CodeQL must upload Swift analysis results to code scanning. + security-events: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e794097..c246a0c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -5,6 +5,10 @@ on: branches: [main] types: [opened, edited, reopened, synchronize] +concurrency: + group: pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: contents: read @@ -17,6 +21,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + ref: ${{ github.event.pull_request.base.sha }} - env: PR_TITLE: ${{ github.event.pull_request.title }} run: scripts/check-conventional-subject.sh "$PR_TITLE" @@ -34,6 +39,9 @@ jobs: - env: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | + validator="$RUNNER_TEMP/check-conventional-subject.sh" + git show "$BASE_SHA:scripts/check-conventional-subject.sh" > "$validator" + chmod +x "$validator" while IFS= read -r subject; do - scripts/check-conventional-subject.sh "$subject" + "$validator" "$subject" done < <(git log --format=%s "$BASE_SHA..HEAD") diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d5585b7..b34de3a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 permissions: + # release-please opens release PRs, updates release metadata, and creates releases. contents: write issues: write pull-requests: write From 7a6d54db9faa2d8cdcc09fce88b8325766625b2c Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:10:37 +0100 Subject: [PATCH 28/47] ci(release): align dependency commit conventions --- .github/CONTRIBUTING.md | 3 +++ release-please-config.json | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 5ec5970..79980de 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -64,6 +64,9 @@ Examples include `feat(status): report battery state` and `feat`, `fix`, `docs`, `test`, `build`, `ci`, `refactor`, `perf`, `style`, `chore`, and `revert`. +Dependency updates use `build(deps): ...` so they remain within the accepted +commit vocabulary and are hidden from the public changelog. + ## Tests Test observable behaviour through public or internal boundaries. Cover failure diff --git a/release-please-config.json b/release-please-config.json index c9439f8..30f73ce 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -19,7 +19,6 @@ {"type": "fix", "section": "Fixes"}, {"type": "perf", "section": "Performance"}, {"type": "docs", "section": "Documentation"}, - {"type": "deps", "section": "Dependencies"}, {"type": "test", "section": "Tests", "hidden": true}, {"type": "build", "section": "Build", "hidden": true}, {"type": "ci", "section": "CI", "hidden": true}, From a64063df64015099f5bb63f23133758f332b4213 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:10:41 +0100 Subject: [PATCH 29/47] build(checks): separate shell assignment --- scripts/check.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/check.sh b/scripts/check.sh index 9955dda..9ce8afc 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -1,7 +1,8 @@ #!/bin/bash set -euo pipefail -readonly repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly repository_root cd "$repository_root" swift_paths=(Package.swift Sources Tests) From 45a173fadfc8a206831c7dee598d8c3acb16df35 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:10:44 +0100 Subject: [PATCH 30/47] docs(quality): align review implementation plan --- .github/pull_request_template.md | 2 +- .../repository-quality-implementation-plan.md | 62 ++++++++++++++----- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 877e521..b773fe0 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,4 +1,4 @@ -## Summary +# Summary diff --git a/docs/repository-quality-implementation-plan.md b/docs/repository-quality-implementation-plan.md index e8ded47..94d9fab 100644 --- a/docs/repository-quality-implementation-plan.md +++ b/docs/repository-quality-implementation-plan.md @@ -75,7 +75,9 @@ Tests/ClamshellCoreTests/ └── Support/InstallationTestSupport.swift Tests/ClamshellCLITests/ -└── Commands/SetupCommandTests.swift +└── Commands/ + ├── SetupCommandTests.swift + └── StatusCommandTests.swift ``` ### Quality and repository files @@ -140,7 +142,7 @@ swift build swift build -c release ``` -Expected: 30 tests in 10 suites pass; both builds exit 0. +Expected: 33 tests in 12 suites pass; both builds exit 0. ## Task 2: Organise source and tests by responsibility @@ -208,7 +210,7 @@ swift test swift build -c release ``` -Expected: all 30 tests pass and the release build exits 0. +Expected: all 33 tests pass and the release build exits 0. - [ ] **Step 5: Commit the mechanical layout change** @@ -312,7 +314,11 @@ PrivilegedInstallation.swift PrivilegedInstallation Keep `InstalledFileAttributes` beside the protocol whose method returns it. Retain every existing path, permission (`0755` and `0440`), ownership check, -temporary-file cleanup, `visudo` validation, and idempotency branch. +temporary-file cleanup, `visudo` validation, and idempotency branch. Stage both +replacement files before validation, validate the staged policy, then replace +the policy before the helper. This avoids changing either live path when +staging or validation fails; the two separate filesystem renames are not a +single transaction. - [ ] **Step 6: Separate CLI composition and output options** @@ -354,7 +360,7 @@ swift build swift build -c release ``` -Expected: all 30 tests pass; both builds exit 0; command output and exit codes +Expected: all 33 tests pass; both builds exit 0; command output and exit codes are unchanged. - [ ] **Step 10: Commit the focused declarations** @@ -498,6 +504,9 @@ Do not attach a build-tool plugin to any target. Resolve the exact dependency: swift package resolve ``` +The CLI test target imports ArgumentParser for command parsing, so it declares +the `ArgumentParser` product directly as well as depending on `ClamshellCLI`. + - [ ] **Step 2: Add a curated non-formatting SwiftLint policy** First extend the `rules` object in `.swift-format` with the documentation rules @@ -627,7 +636,7 @@ swift package plugin --allow-writing-to-package-directory swiftlint --strict swift test ``` -Expected: both linters exit 0 and all 30 tests pass. +Expected: both linters exit 0 and all 33 tests pass. - [ ] **Step 6: Commit linting and API documentation** @@ -664,6 +673,9 @@ if [[ ! "$subject" =~ $pattern ]]; then fi ``` +Dependency updates use `build(deps): ...`; `deps` is not a separate accepted +verb, so dependency commits stay inside the repository's standard vocabulary. + Make it executable and verify both branches: ```bash @@ -682,7 +694,8 @@ Create executable `scripts/check.sh`: #!/bin/bash set -euo pipefail -readonly repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly repository_root cd "$repository_root" swift_paths=(Package.swift Sources Tests) @@ -814,10 +827,14 @@ jobs: - [ ] **Step 2: Add PR title and commit validation** -Create `pr.yml` with two stable jobs, `Title` and `Commits`. Both invoke -`scripts/check-conventional-subject.sh`. Check out the pull request's head SHA -with full history for the commit job so the synthetic merge subject is never -validated. +Create `pr.yml` with two stable jobs, `Title` and `Commits`. The title job +checks out the trusted base revision. The commit job checks out the pull +request's head SHA with full history for the commit range, but extracts the +validator from the trusted base revision before invoking it. The synthetic +merge subject is never validated, and a pull request cannot alter the +validator it uses. The bootstrap pull request that first adds the validator +may require the maintainer's configured administrative bypass until the file +exists on `main`. ```yaml name: PR @@ -827,6 +844,10 @@ on: branches: [main] types: [opened, edited, reopened, synchronize] +concurrency: + group: pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: contents: read @@ -839,6 +860,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + ref: ${{ github.event.pull_request.base.sha }} - env: PR_TITLE: ${{ github.event.pull_request.title }} run: scripts/check-conventional-subject.sh "$PR_TITLE" @@ -856,8 +878,11 @@ jobs: - env: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | + validator="$RUNNER_TEMP/check-conventional-subject.sh" + git show "$BASE_SHA:scripts/check-conventional-subject.sh" > "$validator" + chmod +x "$validator" while IFS= read -r subject; do - scripts/check-conventional-subject.sh "$subject" + "$validator" "$subject" done < <(git log --format=%s "$BASE_SHA..HEAD") ``` @@ -908,13 +933,16 @@ concurrency: permissions: contents: read - security-events: write jobs: analyze: name: Analyze (swift) runs-on: macos-26 timeout-minutes: 30 + permissions: + contents: read + # CodeQL must upload Swift analysis results to code scanning. + security-events: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -974,6 +1002,10 @@ Create `.release-please-manifest.json`: } ``` +The synthetic `0.0.0` root version is intentional: with +`bump-minor-pre-major: true`, the first feature release becomes `0.1.0`. +Release-please does not use an `initial-version` setting in this configuration. + Create `CHANGELOG.md`: ```markdown @@ -1006,7 +1038,6 @@ Create `release-please-config.json`: {"type": "fix", "section": "Fixes"}, {"type": "perf", "section": "Performance"}, {"type": "docs", "section": "Documentation"}, - {"type": "deps", "section": "Dependencies"}, {"type": "test", "section": "Tests", "hidden": true}, {"type": "build", "section": "Build", "hidden": true}, {"type": "ci", "section": "CI", "hidden": true}, @@ -1043,6 +1074,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 permissions: + # release-please opens release PRs, updates release metadata, and creates releases. contents: write issues: write pull-requests: write @@ -1129,7 +1161,7 @@ required fields and labels consistent with `type: bug` and `type: feature`. The pull-request template must include summary, linked issues, behaviour and security impact, verification commands, screenshots only when UI changes, and a checklist covering `scripts/check.sh`, documentation, and scoped commit -subjects. +subjects. Start it with a single top-level `# Summary` heading. - [ ] **Step 4: Align public and internal documentation** From 5553bf9f34d4607bb102d8b91ee371aba8b3093f Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:23:40 +0100 Subject: [PATCH 31/47] ci(pr): bootstrap trusted subject validation --- .github/workflows/pr.yml | 21 ++++++++- .../repository-quality-implementation-plan.md | 46 ++++++------------- 2 files changed, 32 insertions(+), 35 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c246a0c..b70180f 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -22,9 +22,21 @@ jobs: with: persist-credentials: false ref: ${{ github.event.pull_request.base.sha }} + - env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + validator="$RUNNER_TEMP/check-conventional-subject.sh" + if git cat-file -e "$BASE_SHA:scripts/check-conventional-subject.sh" 2>/dev/null; then + git show "$BASE_SHA:scripts/check-conventional-subject.sh" > "$validator" + else + # shellcheck disable=SC2016 + printf '%s\n' '#!/bin/bash' 'set -euo pipefail' 'subject="${1:-}"' "verbs='feat|fix|docs|test|build|ci|refactor|perf|style|chore|revert'" 'pattern="^(feat|fix|docs|test|build|ci|refactor|perf|style|chore|revert)[(][a-z0-9][a-z0-9-]*[)]: .+"' 'if [[ ! "$subject" =~ $pattern ]]; then' ' echo "Expected verb(area): description, received: $subject" >&2' ' exit 1' 'fi' > "$validator" + fi + chmod +x "$validator" - env: PR_TITLE: ${{ github.event.pull_request.title }} - run: scripts/check-conventional-subject.sh "$PR_TITLE" + run: >- + "$RUNNER_TEMP/check-conventional-subject.sh" "$PR_TITLE" commits: name: Commits @@ -40,7 +52,12 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | validator="$RUNNER_TEMP/check-conventional-subject.sh" - git show "$BASE_SHA:scripts/check-conventional-subject.sh" > "$validator" + if git cat-file -e "$BASE_SHA:scripts/check-conventional-subject.sh" 2>/dev/null; then + git show "$BASE_SHA:scripts/check-conventional-subject.sh" > "$validator" + else + # shellcheck disable=SC2016 + printf '%s\n' '#!/bin/bash' 'set -euo pipefail' 'subject="${1:-}"' "verbs='feat|fix|docs|test|build|ci|refactor|perf|style|chore|revert'" 'pattern="^(feat|fix|docs|test|build|ci|refactor|perf|style|chore|revert)[(][a-z0-9][a-z0-9-]*[)]: .+"' 'if [[ ! "$subject" =~ $pattern ]]; then' ' echo "Expected verb(area): description, received: $subject" >&2' ' exit 1' 'fi' > "$validator" + fi chmod +x "$validator" while IFS= read -r subject; do "$validator" "$subject" diff --git a/docs/repository-quality-implementation-plan.md b/docs/repository-quality-implementation-plan.md index 94d9fab..75c8e8a 100644 --- a/docs/repository-quality-implementation-plan.md +++ b/docs/repository-quality-implementation-plan.md @@ -832,58 +832,38 @@ checks out the trusted base revision. The commit job checks out the pull request's head SHA with full history for the commit range, but extracts the validator from the trusted base revision before invoking it. The synthetic merge subject is never validated, and a pull request cannot alter the -validator it uses. The bootstrap pull request that first adds the validator -may require the maintainer's configured administrative bypass until the file -exists on `main`. +validator it uses. If the trusted base predates the validator, both jobs use a +literal bootstrap fallback identical to the script being introduced; later +pull requests use only the base-revision copy. -```yaml -name: PR - -on: - pull_request: - branches: [main] - types: [opened, edited, reopened, synchronize] +The workflow uses the trusted-base validator materialization described above. +Keep the bootstrap fallback literal identical to +`scripts/check-conventional-subject.sh`; the abbreviated shape is: +```yaml concurrency: group: pr-${{ github.event.pull_request.number }} cancel-in-progress: true -permissions: - contents: read - jobs: title: - name: Title - runs-on: ubuntu-latest - timeout-minutes: 5 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@... # pinned SHA with: - persist-credentials: false ref: ${{ github.event.pull_request.base.sha }} - - env: - PR_TITLE: ${{ github.event.pull_request.title }} - run: scripts/check-conventional-subject.sh "$PR_TITLE" + - run: materialize trusted-base validator or the identical bootstrap rule + - run: $RUNNER_TEMP/check-conventional-subject.sh "$PR_TITLE" commits: - name: Commits - runs-on: ubuntu-latest - timeout-minutes: 5 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@... # pinned SHA with: fetch-depth: 0 - persist-credentials: false ref: ${{ github.event.pull_request.head.sha }} - env: BASE_SHA: ${{ github.event.pull_request.base.sha }} - run: | - validator="$RUNNER_TEMP/check-conventional-subject.sh" - git show "$BASE_SHA:scripts/check-conventional-subject.sh" > "$validator" - chmod +x "$validator" - while IFS= read -r subject; do - "$validator" "$subject" - done < <(git log --format=%s "$BASE_SHA..HEAD") + run: materialize trusted-base validator or the identical bootstrap rule + - run: apply the temporary validator to git log --format=%s "$BASE_SHA..HEAD" ``` - [ ] **Step 3: Validate and commit the workflows** From 12b906765720c5741a4f355a0709b33aa8e53f9d Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:05:35 +0100 Subject: [PATCH 32/47] fix(checks): close review validation gaps --- .github/workflows/pr.yml | 12 +++++++++--- docs/implementation-plan.md | 7 ++++++- scripts/check-conventional-subject.sh | 4 ++-- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b70180f..51796a9 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -30,7 +30,7 @@ jobs: git show "$BASE_SHA:scripts/check-conventional-subject.sh" > "$validator" else # shellcheck disable=SC2016 - printf '%s\n' '#!/bin/bash' 'set -euo pipefail' 'subject="${1:-}"' "verbs='feat|fix|docs|test|build|ci|refactor|perf|style|chore|revert'" 'pattern="^(feat|fix|docs|test|build|ci|refactor|perf|style|chore|revert)[(][a-z0-9][a-z0-9-]*[)]: .+"' 'if [[ ! "$subject" =~ $pattern ]]; then' ' echo "Expected verb(area): description, received: $subject" >&2' ' exit 1' 'fi' > "$validator" + printf '%s\n' '#!/bin/bash' 'set -euo pipefail' 'subject="${1:-}"' "verbs='feat|fix|docs|test|build|ci|refactor|perf|style|chore|revert'" 'pattern="^(feat|fix|docs|test|build|ci|refactor|perf|style|chore|revert)[(][a-z0-9][a-z0-9-]*[)]: .*[^.]$"' 'if [[ ! "$subject" =~ $pattern ]]; then' ' echo "Expected verb(area): description without a trailing full stop, received: $subject" >&2' ' exit 1' 'fi' > "$validator" fi chmod +x "$validator" - env: @@ -56,9 +56,15 @@ jobs: git show "$BASE_SHA:scripts/check-conventional-subject.sh" > "$validator" else # shellcheck disable=SC2016 - printf '%s\n' '#!/bin/bash' 'set -euo pipefail' 'subject="${1:-}"' "verbs='feat|fix|docs|test|build|ci|refactor|perf|style|chore|revert'" 'pattern="^(feat|fix|docs|test|build|ci|refactor|perf|style|chore|revert)[(][a-z0-9][a-z0-9-]*[)]: .+"' 'if [[ ! "$subject" =~ $pattern ]]; then' ' echo "Expected verb(area): description, received: $subject" >&2' ' exit 1' 'fi' > "$validator" + printf '%s\n' '#!/bin/bash' 'set -euo pipefail' 'subject="${1:-}"' "verbs='feat|fix|docs|test|build|ci|refactor|perf|style|chore|revert'" 'pattern="^(feat|fix|docs|test|build|ci|refactor|perf|style|chore|revert)[(][a-z0-9][a-z0-9-]*[)]: .*[^.]$"' 'if [[ ! "$subject" =~ $pattern ]]; then' ' echo "Expected verb(area): description without a trailing full stop, received: $subject" >&2' ' exit 1' 'fi' > "$validator" fi chmod +x "$validator" + subjects="$RUNNER_TEMP/commit-subjects.txt" + trap 'rm -f "$subjects"' EXIT + if ! git log --format=%s "$BASE_SHA..HEAD" > "$subjects"; then + echo "Failed to enumerate pull-request commits" >&2 + exit 1 + fi while IFS= read -r subject; do "$validator" "$subject" - done < <(git log --format=%s "$BASE_SHA..HEAD") + done < "$subjects" diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 966903d..40f202c 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -1164,7 +1164,12 @@ git commit -m "ci(checks): verify Swift and pull-request quality" Use `release-type: simple`, root package `.`, `include-v-in-tag: true`, `include-component-in-tag: false`, `bump-minor-pre-major: true`, and `bump-patch-for-minor-pre-major: false`. Configure generic updaters for `Sources/ClamshellCore/BuildVersion.swift` and both `MARKETING_VERSION` entries in `project.yml`. Use the agreed visible changelog sections and hide tests and routine chores. -Initial manifest content is `{}` and the root package sets `initial-version: "0.1.0"`; the first release PR therefore targets `0.1.0`. `version.txt` starts at `0.1.0` and must remain identical to `BuildVersion.current` and the two app `MARKETING_VERSION` values. The consistency script compares the manifest only after release-please has recorded a root version. +Initial manifest content is `{ ".": "0.0.0" }`. Do not set `initial-version`; +with `bump-minor-pre-major: true`, the first feature release targets `0.1.0`. +`version.txt` starts at `0.1.0` and must remain identical to +`BuildVersion.current` and the two app `MARKETING_VERSION` values. The +consistency script treats the synthetic manifest version as the pre-release +bootstrap value until release-please records the first release. - [ ] **Step 2: Add configuration validation** diff --git a/scripts/check-conventional-subject.sh b/scripts/check-conventional-subject.sh index c7446ec..78e422e 100755 --- a/scripts/check-conventional-subject.sh +++ b/scripts/check-conventional-subject.sh @@ -3,9 +3,9 @@ set -euo pipefail readonly subject="${1:-}" readonly verbs='feat|fix|docs|test|build|ci|refactor|perf|style|chore|revert' -readonly pattern="^(${verbs})\([a-z0-9][a-z0-9-]*\): .+" +readonly pattern="^(${verbs})\([a-z0-9][a-z0-9-]*\): .*[^.]$" if [[ ! "$subject" =~ $pattern ]]; then - echo "Expected verb(area): description, received: $subject" >&2 + echo "Expected verb(area): description without a trailing full stop, received: $subject" >&2 exit 1 fi From 2c10be2d231743c378813973ffa51dbeddce6cd1 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:16:52 +0100 Subject: [PATCH 33/47] docs(review): define CodeRabbit policy --- docs/repository-quality-design.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/repository-quality-design.md b/docs/repository-quality-design.md index 2f9279c..f157c06 100644 --- a/docs/repository-quality-design.md +++ b/docs/repository-quality-design.md @@ -189,6 +189,30 @@ publishes results through GitHub code scanning. repository's release-please conventions. The same structure is used for local commits. +### Automated review + +`.coderabbit.yaml` is the version-controlled source of truth for automated +review. [CodeRabbit][coderabbit-config] uses British English and an assertive, +high-signal profile. It prioritises correctness, security, maintainability, and +idiomatic Swift. Review comments must identify a concrete consequence and must +not request cosmetic churn, redundant comments, or documentation for +self-explanatory implementation details. + +CodeRabbit reviews a pull request when it becomes ready for review. It does not +review drafts or rerun automatically after each push. Maintainers batch fixes +and request a manual follow-up review when the result justifies another review. +Actionable findings use GitHub's request changes workflow. + +The walkthrough remains concise and collapsed. It omits poems, fortunes, +sequence diagrams, effort estimates, suggested labels or reviewers, and prompts +for automated code generation. Swift, tests, privileged code, workflows, shell +scripts, and documentation receive focused path-specific instructions. + +The docstring coverage and generation features are disabled. The deterministic +`pr.yml` check owns title syntax, so CodeRabbit does not duplicate it with a +subjective title check. Description and linked-issue checks remain advisory. +SwiftLint reads `.swiftlint.yml`, and ShellCheck remains enabled. + ### Releases and dependency updates `release.yml` uses release-please to create release pull requests and GitHub @@ -264,5 +288,6 @@ The quality pass is complete when: issues. [codeql-compiled]: https://docs.github.com/en/code-security/concepts/code-scanning/codeql/codeql-for-compiled-languages +[coderabbit-config]: https://docs.coderabbit.ai/reference/configuration [google-swift-style]: https://google.github.io/swift/ [swiftlint]: https://github.com/realm/SwiftLint From 74ffd8c0973c182c4e6346f1f5d3aac6ac2a133b Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:19:16 +0100 Subject: [PATCH 34/47] docs(review): plan CodeRabbit configuration --- docs/coderabbit-implementation-plan.md | 212 +++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 docs/coderabbit-implementation-plan.md diff --git a/docs/coderabbit-implementation-plan.md b/docs/coderabbit-implementation-plan.md new file mode 100644 index 0000000..b2bc1fc --- /dev/null +++ b/docs/coderabbit-implementation-plan.md @@ -0,0 +1,212 @@ +# CodeRabbit Configuration Implementation Plan + +**Goal:** Add a version-controlled CodeRabbit policy that produces strict, +high-signal reviews without spending review allowance on drafts or incremental +pushes. + +**Architecture:** `.coderabbit.yaml` owns review timing, presentation, +pre-merge checks, tool configuration, and path-specific guidance. The existing +repository-quality design records the policy, while CodeRabbit's manual +pre-merge command verifies the effective configuration on pull request #11. + +**Tech stack:** CodeRabbit schema v2, YAML, SwiftLint, ShellCheck, GitHub pull +requests. + +--- + +### Task 1: Add the repository configuration + +**Files:** + +- Create: `.coderabbit.yaml` + +- [ ] **Step 1: Replace the minimal configuration** + +Use this complete configuration: + +```yaml +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: "en-GB" +early_access: false + +reviews: + profile: "assertive" + request_changes_workflow: true + high_level_summary: true + high_level_summary_in_walkthrough: true + high_level_summary_instructions: >- + Summarise user-visible behaviour, security boundaries, and verification in + concise British English. Omit release-note filler. + review_status: true + review_details: false + commit_status: true + fail_commit_status: false + collapse_walkthrough: true + changed_files_summary: true + sequence_diagrams: false + estimate_code_review_effort: false + assess_linked_issues: true + related_issues: false + related_prs: false + suggested_labels: false + auto_apply_labels: false + suggested_reviewers: false + in_progress_fortune: false + poem: false + enable_prompt_for_ai_agents: false + abort_on_close: true + + auto_review: + enabled: true + auto_incremental_review: false + drafts: false + + finishing_touches: + docstrings: + enabled: false + + pre_merge_checks: + docstrings: + mode: "off" + title: + mode: "off" + description: + mode: "warning" + issue_assessment: + mode: "warning" + + path_instructions: + - path: "**/*" + instructions: | + Report only findings with a concrete correctness, security, or + maintainability consequence. State the consequence and the smallest + suitable remedy. Do not request cosmetic churn, redundant comments, + or documentation for self-explanatory implementation details. Verify + each finding against the current code and repository conventions. + - path: "**/*.swift" + instructions: | + Follow the Google Swift Style Guide and the repository SwiftLint + policy. Prefer idiomatic Swift 6, narrow access control, Sendable-safe + concurrency, typed errors, and explicit failure handling. Treat one + primary responsibility per file as a design goal, not a rigid + one-declaration rule. + - path: "Sources/ClamshellCore/Privilege/**/*.swift" + instructions: | + Treat helper actions, sudoers generation, filesystem replacement, + ownership, permissions, username handling, and process construction as + security boundaries. Check exact allow-lists and failure consistency. + - path: "Sources/ClamshellHelper/**/*.swift" + instructions: | + Treat every accepted argument and privileged command as part of the + root boundary. Reject broader command shapes and require observable + verification of requested state changes. + - path: "Tests/**/*.swift" + instructions: | + Review observable behaviour, failure paths, and privilege constraints. + Tests must not mutate live power settings or privileged system files. + Do not request assertions about private implementation or call order + unless order is part of the contract. + - path: ".github/workflows/**" + instructions: | + Check immutable action pinning, least-privilege permissions, untrusted + pull-request code execution, concurrency, timeouts, and fail-closed + status handling. Request comments only for non-obvious security + constraints or necessary tool suppressions. + - path: "scripts/**" + instructions: | + Check quoting, exit-status preservation, temporary-file cleanup, and + fail-closed behaviour. Prefer portable shell accepted by ShellCheck. + - path: "**/*.md" + instructions: | + Check technical accuracy against current behaviour. Use concise British + English. Reject stale paths, unsupported claims, and internal AI + process metadata. + + tools: + swiftlint: + enabled: true + config_file: ".swiftlint.yml" + shellcheck: + enabled: true + +chat: + auto_reply: true +``` + +- [ ] **Step 2: Parse the YAML locally** + +Run: + +```bash +ruby -ryaml -e ' + config = YAML.safe_load(File.read(".coderabbit.yaml"), aliases: false) + abort "invalid CodeRabbit config" unless + config.dig("reviews", "profile") == "assertive" && + config.dig("reviews", "auto_review", "drafts") == false && + config.dig("reviews", "auto_review", "auto_incremental_review") == false && + config.dig("reviews", "pre_merge_checks", "docstrings", "mode") == "off" +' +``` + +Expected: exit status 0 with no output. + +- [ ] **Step 3: Check formatting and the repository diff** + +Run: + +```bash +git diff --check +scripts/check.sh +``` + +Expected: no whitespace errors; formatting, SwiftLint, tests, builds, and +workflow lint all pass. + +### Task 2: Publish and verify the effective policy + +**Files:** + +- Add: `.coderabbit.yaml` +- Verify: pull request #11 metadata and CodeRabbit walkthrough + +- [ ] **Step 1: Commit the configuration** + +Run: + +```bash +git add .coderabbit.yaml +git commit -m "ci(review): configure high-signal CodeRabbit reviews" +git push +``` + +Expected: only `.coderabbit.yaml` is included in the commit. + +- [ ] **Step 2: Verify pull-request issue coverage** + +Confirm that pull request #11 states that it partially addresses issues #7 and +#8 without closing either issue. + +- [ ] **Step 3: Run the configured pre-merge checks** + +Post this pull-request comment: + +```text +@coderabbitai run pre-merge checks +``` + +Expected: the docstring and title checks are disabled. Description and linked +issue assessment run as warnings, with the issue assessment recognising #7 and +#8 as partial scope. + +- [ ] **Step 4: Verify the final review state** + +Run: + +```bash +gh pr checks 11 --repo LMLiam/clamshellctl +git status --short --branch +``` + +Expected: required checks pass or remain in progress with no configuration +failure; no CodeRabbit-authored review thread remains unresolved; `.vscode/` +remains untouched. From 153b0a9b640c69d685c9151bf85699f4c3488927 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:20:56 +0100 Subject: [PATCH 35/47] ci(review): configure high-signal CodeRabbit reviews --- .coderabbit.yaml | 106 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..880272a --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,106 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: "en-GB" +early_access: false + +reviews: + profile: "assertive" + request_changes_workflow: true + high_level_summary: true + high_level_summary_in_walkthrough: true + high_level_summary_instructions: >- + Summarise user-visible behaviour, security boundaries, and verification in + concise British English. Omit release-note filler. + review_status: true + review_details: false + commit_status: true + fail_commit_status: false + collapse_walkthrough: true + changed_files_summary: true + sequence_diagrams: false + estimate_code_review_effort: false + assess_linked_issues: true + related_issues: false + related_prs: false + suggested_labels: false + auto_apply_labels: false + suggested_reviewers: false + in_progress_fortune: false + poem: false + enable_prompt_for_ai_agents: false + abort_on_close: true + + auto_review: + enabled: true + auto_incremental_review: false + drafts: false + + finishing_touches: + docstrings: + enabled: false + + pre_merge_checks: + docstrings: + mode: "off" + title: + mode: "off" + description: + mode: "warning" + issue_assessment: + mode: "warning" + + path_instructions: + - path: "**/*" + instructions: | + Report only findings with a concrete correctness, security, or + maintainability consequence. State the consequence and the smallest + suitable remedy. Do not request cosmetic churn, redundant comments, + or documentation for self-explanatory implementation details. Verify + each finding against the current code and repository conventions. + - path: "**/*.swift" + instructions: | + Follow the Google Swift Style Guide and the repository SwiftLint + policy. Prefer idiomatic Swift 6, narrow access control, Sendable-safe + concurrency, typed errors, and explicit failure handling. Treat one + primary responsibility per file as a design goal, not a rigid + one-declaration rule. + - path: "Sources/ClamshellCore/Privilege/**/*.swift" + instructions: | + Treat helper actions, sudoers generation, filesystem replacement, + ownership, permissions, username handling, and process construction as + security boundaries. Check exact allow-lists and failure consistency. + - path: "Sources/ClamshellHelper/**/*.swift" + instructions: | + Treat every accepted argument and privileged command as part of the + root boundary. Reject broader command shapes and require observable + verification of requested state changes. + - path: "Tests/**/*.swift" + instructions: | + Review observable behaviour, failure paths, and privilege constraints. + Tests must not mutate live power settings or privileged system files. + Do not request assertions about private implementation or call order + unless order is part of the contract. + - path: ".github/workflows/**" + instructions: | + Check immutable action pinning, least-privilege permissions, untrusted + pull-request code execution, concurrency, timeouts, and fail-closed + status handling. Request comments only for non-obvious security + constraints or necessary tool suppressions. + - path: "scripts/**" + instructions: | + Check quoting, exit-status preservation, temporary-file cleanup, and + fail-closed behaviour. Prefer portable shell accepted by ShellCheck. + - path: "**/*.md" + instructions: | + Check technical accuracy against current behaviour. Use concise British + English. Reject stale paths, unsupported claims, and internal AI + process metadata. + + tools: + swiftlint: + enabled: true + config_file: ".swiftlint.yml" + shellcheck: + enabled: true + +chat: + auto_reply: true From f5add89fbca22feb2afce85003ed9620d746c76d Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:36:07 +0100 Subject: [PATCH 36/47] ci(workflows): clarify automation steps --- .github/workflows/ci.yml | 30 ++++++++++++++++++++---------- .github/workflows/codeql.yml | 13 ++++++++----- .github/workflows/pr.yml | 15 ++++++++++----- .github/workflows/release.yml | 3 ++- 4 files changed, 40 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5921c6a..c032c1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,41 +20,51 @@ jobs: runs-on: macos-26 timeout-minutes: 15 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - run: swift format lint --recursive --strict Sources Tests Package.swift - - run: swift package plugin --allow-writing-to-package-directory swiftlint --strict + - name: Check Swift formatting + run: swift format lint --recursive --strict Sources Tests Package.swift + - name: Run SwiftLint + run: swift package plugin --allow-writing-to-package-directory swiftlint --strict tests: name: Tests runs-on: macos-26 timeout-minutes: 15 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - run: swift test + - name: Run tests + run: swift test build: name: Build runs-on: macos-26 timeout-minutes: 15 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - run: swift build - - run: swift build -c release + - name: Build debug configuration + run: swift build + - name: Build release configuration + run: swift build -c release workflows: name: Workflow lint runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0 + - name: Lint workflows + uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0 with: version: 1.7.12 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 89835dc..36634d5 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -23,18 +23,21 @@ jobs: timeout-minutes: 30 permissions: contents: read - # CodeQL must upload Swift analysis results to code scanning. security-events: write steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 + - name: Initialise CodeQL + uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 with: build-mode: manual languages: swift queries: security-extended - - run: swift build - - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 + - name: Build for CodeQL + run: swift build + - name: Analyse with CodeQL + uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 with: category: /language:swift diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 51796a9..afc0271 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -18,11 +18,13 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check out trusted base + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false ref: ${{ github.event.pull_request.base.sha }} - - env: + - name: Prepare title validator + env: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | validator="$RUNNER_TEMP/check-conventional-subject.sh" @@ -33,7 +35,8 @@ jobs: printf '%s\n' '#!/bin/bash' 'set -euo pipefail' 'subject="${1:-}"' "verbs='feat|fix|docs|test|build|ci|refactor|perf|style|chore|revert'" 'pattern="^(feat|fix|docs|test|build|ci|refactor|perf|style|chore|revert)[(][a-z0-9][a-z0-9-]*[)]: .*[^.]$"' 'if [[ ! "$subject" =~ $pattern ]]; then' ' echo "Expected verb(area): description without a trailing full stop, received: $subject" >&2' ' exit 1' 'fi' > "$validator" fi chmod +x "$validator" - - env: + - name: Validate pull request title + env: PR_TITLE: ${{ github.event.pull_request.title }} run: >- "$RUNNER_TEMP/check-conventional-subject.sh" "$PR_TITLE" @@ -43,12 +46,14 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check out pull request + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false ref: ${{ github.event.pull_request.head.sha }} - - env: + - name: Validate commit subjects + env: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | validator="$RUNNER_TEMP/check-conventional-subject.sh" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b34de3a..f61516e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,8 @@ jobs: issues: write pull-requests: write steps: - - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5 + - name: Run release-please + uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5 with: token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} config-file: release-please-config.json From e0ce5f45194ec1ff938eebbed0de25a98414721f Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:36:07 +0100 Subject: [PATCH 37/47] docs(conduct): remove placeholder policy --- .github/CODE_OF_CONDUCT.md | 83 -------------------------------------- 1 file changed, 83 deletions(-) delete mode 100644 .github/CODE_OF_CONDUCT.md diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md deleted file mode 100644 index 72dc1e1..0000000 --- a/.github/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,83 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our community include: - -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience -* Focusing on what is best not just for us as individuals, but for the overall community - -Examples of unacceptable behavior include: - -* The use of sexualized language or imagery, and sexual attention or advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. - -## Scope - -This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [https://github.com/LMLiam](https://github.com/LMLiam). All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact**: A violation through a single incident or series of actions. - -**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within the community. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. - -Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. - -For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. - -[homepage]: https://www.contributor-covenant.org -[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html -[Mozilla CoC]: https://github.com/mozilla/diversity -[FAQ]: https://www.contributor-covenant.org/faq -[translations]: https://www.contributor-covenant.org/translations From a485679ca89ebf0453922c9785963cd46b299eae Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:38:04 +0100 Subject: [PATCH 38/47] ci(codeql): resolve dependencies before tracing --- .github/workflows/codeql.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 36634d5..9fc9195 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,6 +29,8 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Resolve Swift dependencies + run: swift package resolve - name: Initialise CodeQL uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 with: From dda5e2cb60d4f0df362b0d92f1332a5f4db99815 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:40:37 +0100 Subject: [PATCH 39/47] ci(cache): reuse Swift dependencies --- .github/workflows/ci.yml | 36 ++++++++++++++++++++++++++++++++++++ .github/workflows/codeql.yml | 10 ++++++++++ 2 files changed, 46 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c032c1f..ab86a79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,18 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Restore Swift dependencies + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + .build/artifacts + .build/checkouts + .build/repositories + ~/Library/Caches/org.swift.swiftpm + key: swiftpm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Package.resolved') }} + restore-keys: swiftpm-${{ runner.os }}-${{ runner.arch }}- + - name: Resolve Swift dependencies + run: swift package resolve - name: Check Swift formatting run: swift format lint --recursive --strict Sources Tests Package.swift - name: Run SwiftLint @@ -38,6 +50,18 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Restore Swift dependencies + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + .build/artifacts + .build/checkouts + .build/repositories + ~/Library/Caches/org.swift.swiftpm + key: swiftpm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Package.resolved') }} + restore-keys: swiftpm-${{ runner.os }}-${{ runner.arch }}- + - name: Resolve Swift dependencies + run: swift package resolve - name: Run tests run: swift test @@ -50,6 +74,18 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Restore Swift dependencies + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + .build/artifacts + .build/checkouts + .build/repositories + ~/Library/Caches/org.swift.swiftpm + key: swiftpm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Package.resolved') }} + restore-keys: swiftpm-${{ runner.os }}-${{ runner.arch }}- + - name: Resolve Swift dependencies + run: swift package resolve - name: Build debug configuration run: swift build - name: Build release configuration diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9fc9195..3404b75 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,6 +29,16 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Restore Swift dependencies + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + .build/artifacts + .build/checkouts + .build/repositories + ~/Library/Caches/org.swift.swiftpm + key: swiftpm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Package.resolved') }} + restore-keys: swiftpm-${{ runner.os }}-${{ runner.arch }}- - name: Resolve Swift dependencies run: swift package resolve - name: Initialise CodeQL From 631e2640e5a80f89221711eb9328f99a26f25324 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:54:12 +0100 Subject: [PATCH 40/47] build(lint): decouple SwiftLint tooling --- .github/CONTRIBUTING.md | 7 ++-- .github/workflows/ci.yml | 16 ++------- Package.resolved | 11 +----- Package.swift | 6 +--- docs/repository-quality-design.md | 9 ++--- .../repository-quality-implementation-plan.md | 34 ++++++++----------- scripts/check.sh | 11 +++++- 7 files changed, 39 insertions(+), 55 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 79980de..b6eac32 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -2,11 +2,12 @@ ## Prerequisites -You need macOS, Xcode 26.6 or later, Swift 6.3 or later, and -[actionlint](https://github.com/rhysd/actionlint). Install actionlint with: +You need macOS, Xcode 26.6 or later, and Swift 6.3 or later. You also need +SwiftLint 0.65.0 and [actionlint](https://github.com/rhysd/actionlint). +Install the development tools with: ```bash -brew install actionlint +brew install actionlint swiftlint ``` The future companion app also requires diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab86a79..cfdd857 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,22 +24,12 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Restore Swift dependencies - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - .build/artifacts - .build/checkouts - .build/repositories - ~/Library/Caches/org.swift.swiftpm - key: swiftpm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Package.resolved') }} - restore-keys: swiftpm-${{ runner.os }}-${{ runner.arch }}- - - name: Resolve Swift dependencies - run: swift package resolve - name: Check Swift formatting run: swift format lint --recursive --strict Sources Tests Package.swift - name: Run SwiftLint - run: swift package plugin --allow-writing-to-package-directory swiftlint --strict + run: | + test "$(swiftlint version)" = "0.65.0" + swiftlint lint --strict tests: name: Tests diff --git a/Package.resolved b/Package.resolved index b4a3d6d..beba77e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "84d535480b2e1e6cd6341ccce330d6f41d7f10625f9ac0bf6753df9d8c1964d0", + "originHash" : "c76775ae7c565ad46d8e466c810d8e0bcad4db0218bc3765c46a215e2227fe90", "pins" : [ { "identity" : "swift-argument-parser", @@ -9,15 +9,6 @@ "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", "version" : "1.8.2" } - }, - { - "identity" : "swiftlintplugins", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SimplyDanny/SwiftLintPlugins", - "state" : { - "revision" : "8a4640d14777685ba8f14e832373160498fbab92", - "version" : "0.63.2" - } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index d742eda..c3c2d4a 100644 --- a/Package.swift +++ b/Package.swift @@ -13,11 +13,7 @@ let package = Package( .package( url: "https://github.com/apple/swift-argument-parser", from: "1.8.0" - ), - .package( - url: "https://github.com/SimplyDanny/SwiftLintPlugins", - exact: "0.63.2" - ), + ) ], targets: [ .target(name: "ClamshellCore"), diff --git a/docs/repository-quality-design.md b/docs/repository-quality-design.md index f157c06..e696351 100644 --- a/docs/repository-quality-design.md +++ b/docs/repository-quality-design.md @@ -22,10 +22,11 @@ tools enforce separate parts of that standard: formatting cannot express. Its checked-in `.swiftlint.yml` covers naming, API hygiene, unsafe constructs, complexity, and selected opt-in rules. -The SwiftLint command plugin is an exact Swift Package Manager dependency. The -project does not attach it as an Xcode build plugin. Local development and CI -therefore use the same version without an Xcode trust prompt. Dependabot keeps -the pinned version current through normal pull requests. +SwiftLint is a standalone development tool. It is not a production package +dependency. Local verification requires version 0.65.0. The GitHub +`macos-26` runner also includes this version. This configuration keeps lint +tools out of production builds and CodeQL builds. Local checks and CI use the +same rules. SwiftLint configuration must be deliberate. The project does not enable every opt-in rule, duplicate formatting rules owned by `swift-format`, or accept a diff --git a/docs/repository-quality-implementation-plan.md b/docs/repository-quality-implementation-plan.md index 75c8e8a..eb9347f 100644 --- a/docs/repository-quality-implementation-plan.md +++ b/docs/repository-quality-implementation-plan.md @@ -10,7 +10,7 @@ interactions behind protocols, and give formatting, linting, testing, security, and repository policy separate enforceable owners. **Tech Stack:** Swift 6.3, Swift Package Manager, Swift Testing, -`swift-format`, SwiftLint 0.63.2, GitHub Actions, CodeQL, GitGuardian, +`swift-format`, SwiftLint 0.65.0, GitHub Actions, CodeQL, GitGuardian, release-please v5, Dependabot, `actionlint`, and GitHub rulesets. --- @@ -482,28 +482,24 @@ git commit -m "style(swift): adopt Google formatting" **Files:** - Modify: `Package.swift` -- Modify: `Package.resolved` - Modify: `.swift-format` - Create: `.swiftlint.yml` - Modify: public declarations under `Sources/` -- [ ] **Step 1: Pin the SwiftLint command plugin** +- [ ] **Step 1: Require standalone SwiftLint** -Add this exact dependency after `swift-argument-parser` in `Package.swift`: - -```swift -.package( - url: "https://github.com/SimplyDanny/SwiftLintPlugins", - exact: "0.63.2" -), -``` - -Do not attach a build-tool plugin to any target. Resolve the exact dependency: +Use SwiftLint 0.65.0 as a standalone tool. Do not add lint tools to the +production package graph. The GitHub `macos-26` runner includes this version. +Install it locally with Homebrew: ```bash -swift package resolve +brew install swiftlint +test "$(swiftlint version)" = "0.65.0" ``` +Do not add SwiftLint or its plugins to `Package.swift`. Production builds do +not need lint tools. CodeQL analysis does not need lint tools. + The CLI test target imports ArgumentParser for command parsing, so it declares the `ArgumentParser` product directly as well as depending on `ClamshellCLI`. @@ -598,7 +594,7 @@ reporter: xcode Run: ```bash -swift package plugin --allow-writing-to-package-directory swiftlint --strict +swiftlint lint --strict ``` Expected: non-zero until every reported correctness, naming, and complexity @@ -632,7 +628,7 @@ Run after each group of fixes: ```bash swift format --recursive --in-place Sources Tests Package.swift swift format lint --recursive --strict Sources Tests Package.swift -swift package plugin --allow-writing-to-package-directory swiftlint --strict +swiftlint lint --strict swift test ``` @@ -643,7 +639,7 @@ Expected: both linters exit 0 and all 33 tests pass. Run: ```bash -git add .swift-format .swiftlint.yml Package.swift Package.resolved Sources Tests +git add .swift-format .swiftlint.yml Package.swift Sources Tests git commit -m "build(lint): enforce Swift conventions" ``` @@ -704,7 +700,7 @@ if [[ -d App ]]; then fi swift format lint --recursive --strict "${swift_paths[@]}" -swift package plugin --allow-writing-to-package-directory swiftlint --strict +swiftlint lint --strict swift test swift build swift build -c release @@ -789,7 +785,7 @@ jobs: with: persist-credentials: false - run: swift format lint --recursive --strict Sources Tests Package.swift - - run: swift package plugin --allow-writing-to-package-directory swiftlint --strict + - run: swiftlint lint --strict tests: name: Tests diff --git a/scripts/check.sh b/scripts/check.sh index 9ce8afc..4810d81 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -10,8 +10,17 @@ if [[ -d App ]]; then swift_paths+=(App) fi +command -v swiftlint >/dev/null || { + echo "SwiftLint is required: brew install swiftlint" >&2 + exit 1 +} +[[ "$(swiftlint version)" == "0.65.0" ]] || { + echo "SwiftLint 0.65.0 is required" >&2 + exit 1 +} + swift format lint --recursive --strict "${swift_paths[@]}" -swift package plugin --allow-writing-to-package-directory swiftlint --strict +swiftlint lint --strict swift test swift build swift build -c release From f08d4016aefe692d697740709b79c0e8e390dc0a Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:54:49 +0100 Subject: [PATCH 41/47] docs(style): require simplified technical English --- .coderabbit.yaml | 13 +++++++++---- .github/CONTRIBUTING.md | 11 +++++++++++ .github/pull_request_template.md | 1 + docs/repository-quality-design.md | 10 ++++++++++ 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 880272a..d6c9414 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -9,7 +9,10 @@ reviews: high_level_summary_in_walkthrough: true high_level_summary_instructions: >- Summarise user-visible behaviour, security boundaries, and verification in - concise British English. Omit release-note filler. + concise British English. Use ASD-STE100 Simplified Technical English for + documentation. Keep required technical terms, product names, commands, + identifiers, quoted interface text, and standard names. Omit release-note + filler. review_status: true review_details: false commit_status: true @@ -91,9 +94,11 @@ reviews: fail-closed behaviour. Prefer portable shell accepted by ShellCheck. - path: "**/*.md" instructions: | - Check technical accuracy against current behaviour. Use concise British - English. Reject stale paths, unsupported claims, and internal AI - process metadata. + Check technical accuracy against current behaviour. Require ASD-STE100 + Simplified Technical English and British English spelling. Permit + required technical terms, product names, commands, identifiers, quoted + interface text, and standard names. Reject stale paths, unsupported + claims, and internal AI process metadata. tools: swiftlint: diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index b6eac32..cfda072 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -52,6 +52,17 @@ directories. Fix lint findings in code where practical. A SwiftLint suppression must have a narrow scope and an adjacent explanation of why the rule does not apply. +## Documentation style + +Use ASD-STE100 Simplified Technical English for all documentation. Use British +English spelling. Use short sentences and active voice. Give one instruction +in each sentence. Use one term for each item or action. + +You can use an unapproved term when technical accuracy requires it. Examples +include product names, commands, code identifiers, API names, quoted interface +text, and standard names. Do not replace a precise technical term with an +ambiguous word. + ## Commits Every commit and pull-request title must use: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b773fe0..d237ed6 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -22,5 +22,6 @@ - [ ] `scripts/check.sh` passes. - [ ] User and contributor documentation matches the change. +- [ ] Documentation uses ASD-STE100 Simplified Technical English. - [ ] Every commit uses `verb(area): description`. - [ ] Privileged behaviour is covered by safe tests rather than live system mutation. diff --git a/docs/repository-quality-design.md b/docs/repository-quality-design.md index e696351..85be3ad 100644 --- a/docs/repository-quality-design.md +++ b/docs/repository-quality-design.md @@ -39,6 +39,16 @@ files: UTF-8, LF endings, a final newline, trailing-whitespace handling, and two-space indentation where appropriate. It complements the Swift tools; it is not the source of truth for Swift syntax formatting. +## Documentation style + +All documentation uses ASD-STE100 Simplified Technical English and British +English spelling. Sentences are short and use active voice. Each instruction +contains one action. Each item or action has one term. + +Technical accuracy has priority over the controlled vocabulary. Product names, +commands, code identifiers, API names, quoted interface text, and standard +names can use their required terms. + ## Source organisation Directories describe responsibilities rather than current consumers. The core From cecef35f2c7b43ac9adfac5ff15a75837455a57c Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:56:34 +0100 Subject: [PATCH 42/47] ci(codeql): prebuild external dependencies --- .github/workflows/codeql.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 3404b75..9115f58 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,6 +41,8 @@ jobs: restore-keys: swiftpm-${{ runner.os }}-${{ runner.arch }}- - name: Resolve Swift dependencies run: swift package resolve + - name: Build external Swift dependencies + run: swift build --target ArgumentParser - name: Initialise CodeQL uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 with: From ffe3002eef249c97f835c5cd3edb4af1fc7fd212 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:14:36 +0100 Subject: [PATCH 43/47] revert(codeql): remove ineffective dependency prebuild --- .github/workflows/codeql.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9115f58..3404b75 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,8 +41,6 @@ jobs: restore-keys: swiftpm-${{ runner.os }}-${{ runner.arch }}- - name: Resolve Swift dependencies run: swift package resolve - - name: Build external Swift dependencies - run: swift build --target ArgumentParser - name: Initialise CodeQL uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 with: From 96b692d7eafdff3cd6cea509f5f402bcc791d4e7 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:17:38 +0100 Subject: [PATCH 44/47] fix(ci): install pinned SwiftLint binary --- .github/workflows/ci.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfdd857..a3b06c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,18 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Install SwiftLint + env: + SWIFTLINT_SHA256: d6cb0aa7a2f5f1ef306fc9e37bcb54dc9a26facc8f7784ac0c3dd3eccf5c6ba6 + SWIFTLINT_VERSION: 0.65.0 + run: | + archive="${RUNNER_TEMP}/portable_swiftlint.zip" + curl --fail --location --silent --show-error \ + --output "${archive}" \ + "https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/portable_swiftlint.zip" + echo "${SWIFTLINT_SHA256} ${archive}" | shasum -a 256 --check + unzip -j "${archive}" swiftlint -d "${RUNNER_TEMP}/swiftlint" + echo "${RUNNER_TEMP}/swiftlint" >> "${GITHUB_PATH}" - name: Check Swift formatting run: swift format lint --recursive --strict Sources Tests Package.swift - name: Run SwiftLint From 56555c0b2d33c31435f1952f656339ebd69e665b Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:22:16 +0100 Subject: [PATCH 45/47] perf(codeql): remove pull request scan --- .github/workflows/codeql.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 3404b75..9ad40a2 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,8 +1,6 @@ name: CodeQL on: - pull_request: - branches: [main] push: branches: [main] schedule: @@ -10,7 +8,7 @@ on: workflow_dispatch: concurrency: - group: codeql-${{ github.event.pull_request.number || github.ref }} + group: codeql-${{ github.ref }} cancel-in-progress: true permissions: From 6fd7e27633520c209788cf718a593412c1ca292b Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:25:05 +0100 Subject: [PATCH 46/47] docs(review): correct implementation plans --- docs/coderabbit-implementation-plan.md | 10 +++++----- docs/implementation-plan.md | 4 +++- docs/repository-quality-design.md | 3 ++- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/coderabbit-implementation-plan.md b/docs/coderabbit-implementation-plan.md index b2bc1fc..b4d6543 100644 --- a/docs/coderabbit-implementation-plan.md +++ b/docs/coderabbit-implementation-plan.md @@ -14,7 +14,7 @@ requests. --- -### Task 1: Add the repository configuration +## Task 1: Add the repository configuration **Files:** @@ -183,8 +183,8 @@ Expected: only `.coderabbit.yaml` is included in the commit. - [ ] **Step 2: Verify pull-request issue coverage** -Confirm that pull request #11 states that it partially addresses issues #7 and -#8 without closing either issue. +Confirm that pull request #11 states that it partially addresses issues #7 and #8 +without closing either issue. - [ ] **Step 3: Run the configured pre-merge checks** @@ -195,8 +195,8 @@ Post this pull-request comment: ``` Expected: the docstring and title checks are disabled. Description and linked -issue assessment run as warnings, with the issue assessment recognising #7 and -#8 as partial scope. +issue assessment run as warnings, with the issue assessment recognising #7 and #8 +as partial scope. - [ ] **Step 4: Verify the final review state** diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 40f202c..3e819e6 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -1129,7 +1129,9 @@ Use a portable anchored regular expression for the allowed Conventional Commit v `ci.yml` runs on pushes to `main` and pull requests, uses macOS runners, checks out pinned action SHAs, and runs formatting, SwiftLint, tests, and debug and release builds. Add XcodeGen and an unsigned `xcodebuild` when the native targets exist. It grants `contents: read` only. -`pr.yml` validates the pull-request title without checking out or executing pull-request code. It grants `pull-requests: read` only. +`pr.yml` validates the pull-request title and commit subjects. It checks out the +trusted base and pull-request head. It executes only the trusted validator and +grants `contents: read` only. - [ ] **Step 4: Verify locally and with actionlint** diff --git a/docs/repository-quality-design.md b/docs/repository-quality-design.md index 85be3ad..0c5a246 100644 --- a/docs/repository-quality-design.md +++ b/docs/repository-quality-design.md @@ -169,7 +169,8 @@ in order: 2. SwiftLint in strict lint mode. 3. The complete Swift test suite. 4. Debug and release package builds. -5. XcodeGen and the companion-app build once those targets exist. +5. `actionlint` for GitHub Actions workflows. +6. XcodeGen and the companion-app build once those targets exist. Each command stops on failure and preserves the failing tool's output. CI uses the same commands rather than maintaining an independent implementation. From 30f4a6202dc3b2b2d449d74f534e7eaec8b7c621 Mon Sep 17 00:00:00 2001 From: LMLiam <46268350+TheRealEmissions@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:31:01 +0100 Subject: [PATCH 47/47] docs(review): clarify verification execution --- docs/repository-quality-design.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/repository-quality-design.md b/docs/repository-quality-design.md index 0c5a246..205347c 100644 --- a/docs/repository-quality-design.md +++ b/docs/repository-quality-design.md @@ -172,8 +172,9 @@ in order: 5. `actionlint` for GitHub Actions workflows. 6. XcodeGen and the companion-app build once those targets exist. -Each command stops on failure and preserves the failing tool's output. CI uses -the same commands rather than maintaining an independent implementation. +Each local command stops on failure and preserves the failing tool's output. +CI runs the same checks in separate jobs to provide parallel feedback. The +script remains the single local entry point. ## GitHub Actions