-
Notifications
You must be signed in to change notification settings - Fork 1
Add --verify to check the built package against build-info #24
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
15821e9
Add --verify to check the built package against build-info
rodchristiansen 6448b87
Verify package identifier and version, and make verifier tests hermetic
rodchristiansen 5cae3f1
Fail verification on package expansion errors and incomplete metadata
rodchristiansen 24e2222
Merge next into feat/verify
jordancalhoun File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| import Foundation | ||
|
|
||
| /// Post-build verification: asserts the finished package matches what build-info | ||
| /// declared. A belt-and-suspenders companion to the notarization failure checks. | ||
| struct PackageVerifier { | ||
| let runner: any ProcessRunning | ||
| let console: Console | ||
| var fileManager: FileManager = .default | ||
|
|
||
| /// - Parameters: | ||
| /// - expectedIdentifier: the `identifier` build-info declared. | ||
| /// - expectedVersion: the `version` build-info declared. | ||
| /// - signed: signing was requested, so a valid signature must be present. | ||
| /// - notarized: notarization was requested, so Gatekeeper must accept it. | ||
| func verify(package: URL, expectedIdentifier: String, expectedVersion: String, signed: Bool, notarized: Bool) throws { | ||
| try verifyMetadata(package: package, expectedIdentifier: expectedIdentifier, expectedVersion: expectedVersion) | ||
| if signed { | ||
| let result = try runner.run(executable: ToolPaths.pkgutil, arguments: ["--check-signature", package.path]) | ||
| guard result.status == 0 else { | ||
| throw MunkiPkgError.message("Verification failed: package is not validly signed. \(diagnostics(result))") | ||
| } | ||
| console.display("Verified package signature") | ||
| } | ||
| if notarized { | ||
| let result = try runner.run(executable: ToolPaths.spctl, arguments: ["-a", "-vvv", "-t", "install", package.path]) | ||
| guard result.status == 0 else { | ||
| throw MunkiPkgError.message("Verification failed: package does not pass Gatekeeper assessment. \(diagnostics(result))") | ||
| } | ||
| console.display("Verified Gatekeeper assessment") | ||
| } | ||
| } | ||
|
|
||
| /// Confirms the built package embeds the identifier and version build-info | ||
| /// declared, so a stale or mismatched artifact can't silently pass `--verify`. | ||
| /// | ||
| /// Best-effort: component packages carry a top-level `PackageInfo`; if it | ||
| /// can't be extracted (e.g. a distribution-style package, whose metadata | ||
| /// lives elsewhere), the check is skipped rather than failing the build. | ||
| private func verifyMetadata(package: URL, expectedIdentifier: String, expectedVersion: String) throws { | ||
| let scratch = fileManager.temporaryDirectory.appendingPathComponent("swiftpkg-verify-\(UUID().uuidString)", isDirectory: true) | ||
| defer { try? fileManager.removeItem(at: scratch) } | ||
| let result = try runner.run(executable: ToolPaths.pkgutil, arguments: ["--expand", package.path, scratch.path]) | ||
| guard result.status == 0 else { | ||
| throw MunkiPkgError.message("Verification failed: could not expand \(package.lastPathComponent) to inspect its metadata. \(diagnostics(result))") | ||
| } | ||
| // A component package carries a top-level PackageInfo. If it's absent | ||
| // (e.g. a distribution-style package, whose metadata lives elsewhere) | ||
| // the metadata check is skipped rather than failing the build. | ||
| guard let data = try? Data(contentsOf: scratch.appendingPathComponent("PackageInfo")), | ||
| let xml = String(data: data, encoding: .utf8) | ||
| else { return } | ||
| if let mismatch = Self.metadataMismatch(expectedIdentifier: expectedIdentifier, expectedVersion: expectedVersion, packageInfoXML: xml) { | ||
| throw MunkiPkgError.message("Verification failed: \(mismatch)") | ||
| } | ||
| console.display("Verified package identifier and version") | ||
| } | ||
|
|
||
| /// Parses a `PackageInfo` document and returns a human-readable message if | ||
| /// its `identifier`/`version` differ from what was expected, else `nil`. | ||
| /// Pure and side-effect free so it can be unit-tested without a subprocess. | ||
| static func metadataMismatch(expectedIdentifier: String, expectedVersion: String, packageInfoXML: String) -> String? { | ||
| let parser = XMLParser(data: Data(packageInfoXML.utf8)) | ||
| let delegate = PackageInfoAttributes() | ||
| parser.delegate = delegate | ||
| guard parser.parse(), let actual = delegate.pkgInfo else { return nil } | ||
| // A PackageInfo we could parse but that omits identifier/version is | ||
| // incomplete and must not silently pass. | ||
| guard let identifier = actual["identifier"] else { | ||
| return "package PackageInfo is missing an identifier." | ||
| } | ||
| if identifier != expectedIdentifier { | ||
| return "package identifier is \"\(identifier)\" but build-info declares \"\(expectedIdentifier)\"." | ||
| } | ||
| guard let version = actual["version"] else { | ||
| return "package PackageInfo is missing a version." | ||
| } | ||
| if version != expectedVersion { | ||
| return "package version is \"\(version)\" but build-info declares \"\(expectedVersion)\"." | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| return nil | ||
| } | ||
|
|
||
| private func diagnostics(_ result: ProcessResult) -> String { | ||
| let text = (result.stderrString + result.stdoutString).trimmingCharacters(in: .whitespacesAndNewlines) | ||
| return text.isEmpty ? "(no output)" : text | ||
| } | ||
| } | ||
|
|
||
| /// Captures the attributes of a `PackageInfo`'s root `pkg-info` element. | ||
| private final class PackageInfoAttributes: NSObject, XMLParserDelegate { | ||
| private(set) var pkgInfo: [String: String]? | ||
|
|
||
| func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) { | ||
| if elementName == "pkg-info", pkgInfo == nil { pkgInfo = attributeDict } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| import Foundation | ||
| import Testing | ||
| @testable import SwiftPkgCore | ||
|
|
||
| struct PackageVerifierTests { | ||
| private func makePackage() throws -> (TemporaryDirectory, URL) { | ||
| let temp = try TemporaryDirectory() | ||
| return (temp, temp.url.appendingPathComponent("App-1.0.pkg")) | ||
| } | ||
|
|
||
| /// Expansion succeeds (so metadata is skipped when no real PackageInfo is | ||
| /// produced) while the named check tool returns a failing status. | ||
| private func runnerFailing(_ failingTool: String) -> RecordingRunner { | ||
| let runner = RecordingRunner() | ||
| runner.resultProvider = { executable, arguments in | ||
| if arguments.contains("--expand") { return ProcessResult(status: 0, stdout: Data(), stderr: Data()) } | ||
| let status: Int32 = executable == failingTool ? 1 : 0 | ||
| return ProcessResult(status: status, stdout: Data(), stderr: Data("bad".utf8)) | ||
| } | ||
| return runner | ||
| } | ||
|
|
||
| @Test("an unsigned, un-notarized build only introspects metadata") | ||
| func metadataOnlyWhenNothingDeclared() throws { | ||
| let (temp, package) = try makePackage(); defer { temp.remove() } | ||
| let runner = RecordingRunner() | ||
| try PackageVerifier(runner: runner, console: makeConsole()) | ||
| .verify(package: package, expectedIdentifier: "com.example.app", expectedVersion: "1.0", signed: false, notarized: false) | ||
| #expect(runner.calls.contains { $0.executable == ToolPaths.pkgutil && $0.arguments.contains("--expand") }) | ||
| #expect(!runner.calls.contains { $0.arguments.contains("--check-signature") }) | ||
| #expect(!runner.calls.contains { $0.executable == ToolPaths.spctl }) | ||
| } | ||
|
|
||
| @Test("a signed build checks the signature and passes when valid") | ||
| func signedPasses() throws { | ||
| let (temp, package) = try makePackage(); defer { temp.remove() } | ||
| let runner = RecordingRunner() | ||
| try PackageVerifier(runner: runner, console: makeConsole()) | ||
| .verify(package: package, expectedIdentifier: "com.example.app", expectedVersion: "1.0", signed: true, notarized: false) | ||
| #expect(runner.calls.contains { $0.executable == ToolPaths.pkgutil && $0.arguments.contains("--expand") }) | ||
| #expect(runner.calls.contains { $0.executable == ToolPaths.pkgutil && $0.arguments.contains("--check-signature") }) | ||
| } | ||
|
|
||
| @Test("a signed build fails when the signature check fails") | ||
| func signedFails() throws { | ||
| let (temp, package) = try makePackage(); defer { temp.remove() } | ||
| let runner = runnerFailing(ToolPaths.pkgutil) | ||
| #expect(throws: MunkiPkgError.self) { | ||
| try PackageVerifier(runner: runner, console: makeConsole()) | ||
| .verify(package: package, expectedIdentifier: "com.example.app", expectedVersion: "1.0", signed: true, notarized: false) | ||
| } | ||
| } | ||
|
|
||
| @Test("verification fails when the package cannot be expanded") | ||
| func failsWhenExpansionFails() throws { | ||
| let (temp, package) = try makePackage(); defer { temp.remove() } | ||
| let runner = RecordingRunner() | ||
| runner.result = ProcessResult(status: 1, stdout: Data(), stderr: Data("corrupt".utf8)) | ||
| #expect(throws: MunkiPkgError.self) { | ||
| try PackageVerifier(runner: runner, console: makeConsole()) | ||
| .verify(package: package, expectedIdentifier: "com.example.app", expectedVersion: "1.0", signed: false, notarized: false) | ||
| } | ||
| } | ||
|
|
||
| @Test("a notarized build runs a Gatekeeper assessment") | ||
| func notarizedRunsSpctl() throws { | ||
| let (temp, package) = try makePackage(); defer { temp.remove() } | ||
| let runner = runnerFailing(ToolPaths.spctl) | ||
| #expect(throws: MunkiPkgError.self) { | ||
| try PackageVerifier(runner: runner, console: makeConsole()) | ||
| .verify(package: package, expectedIdentifier: "com.example.app", expectedVersion: "1.0", signed: false, notarized: true) | ||
| } | ||
| #expect(runner.calls.contains { $0.executable == ToolPaths.spctl && $0.arguments.contains("install") }) | ||
| } | ||
|
|
||
| private let packageInfo = #"<?xml version="1.0" encoding="utf-8"?><pkg-info identifier="com.example.app" version="1.0" install-location="/"/>"# | ||
|
|
||
| @Test("matching identifier and version produce no mismatch") | ||
| func metadataMatches() { | ||
| #expect(PackageVerifier.metadataMismatch(expectedIdentifier: "com.example.app", expectedVersion: "1.0", packageInfoXML: packageInfo) == nil) | ||
| } | ||
|
|
||
| @Test("a mismatched identifier is reported") | ||
| func identifierMismatch() { | ||
| let message = PackageVerifier.metadataMismatch(expectedIdentifier: "com.example.other", expectedVersion: "1.0", packageInfoXML: packageInfo) | ||
| #expect(message?.contains("identifier") == true) | ||
| } | ||
|
|
||
| @Test("a mismatched version is reported") | ||
| func versionMismatch() { | ||
| let message = PackageVerifier.metadataMismatch(expectedIdentifier: "com.example.app", expectedVersion: "2.0", packageInfoXML: packageInfo) | ||
| #expect(message?.contains("version") == true) | ||
| } | ||
|
|
||
| @Test("unparseable PackageInfo is treated as no mismatch (best-effort)") | ||
| func malformedPackageInfoSkips() { | ||
| #expect(PackageVerifier.metadataMismatch(expectedIdentifier: "com.example.app", expectedVersion: "1.0", packageInfoXML: "not xml") == nil) | ||
| } | ||
|
|
||
| @Test("a parsed PackageInfo missing identifier or version is rejected") | ||
| func incompleteMetadataRejected() { | ||
| let noIdentifier = #"<pkg-info version="1.0"/>"# | ||
| let noVersion = #"<pkg-info identifier="com.example.app"/>"# | ||
| #expect(PackageVerifier.metadataMismatch(expectedIdentifier: "com.example.app", expectedVersion: "1.0", packageInfoXML: noIdentifier)?.contains("identifier") == true) | ||
| #expect(PackageVerifier.metadataMismatch(expectedIdentifier: "com.example.app", expectedVersion: "1.0", packageInfoXML: noVersion)?.contains("version") == true) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.