Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions scripts/verify-loop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ for format in json yaml; do
test -f "$PROJECT_FORMAT/build/Format-$format-1.0.pkg"
done

VERIFY="$WORK/Verify"
run "$BIN" --create "$VERIFY"
mkdir -p "$VERIFY/payload/usr/local/bin"
printf '%s\n' '#!/bin/sh' 'exit 0' > "$VERIFY/payload/usr/local/bin/tool"
run "$BIN" --verify "$VERIFY"
test -f "$VERIFY/build/Verify-1.0.pkg"

LINTGOOD="$WORK/LintGood"
run "$BIN" --create "$LINTGOOD"
mkdir -p "$LINTGOOD/payload"
Expand Down
6 changes: 6 additions & 0 deletions swiftpkg/PackageBuildOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ public struct PackageBuildOptions: Sendable {
public let skipsSigning: Bool
public let skipsNotarization: Bool
public let skipsStapling: Bool
/// After building, assert the package matches what build-info declared
/// (signature present when signing was requested, Gatekeeper-accepted when
/// notarized). Fails the build on mismatch.
public let verifies: Bool
/// Overrides the build-info version (resolved before `${version}` substitution).
public let versionOverride: String?
/// Writes the package here instead of the project's `build/` directory.
Expand All @@ -20,6 +24,7 @@ public struct PackageBuildOptions: Sendable {
skipsSigning: Bool = false,
skipsNotarization: Bool = false,
skipsStapling: Bool = false,
verifies: Bool = false,
versionOverride: String? = nil,
outputDirectory: URL? = nil
) {
Expand All @@ -29,6 +34,7 @@ public struct PackageBuildOptions: Sendable {
self.skipsSigning = skipsSigning
self.skipsNotarization = skipsNotarization
self.skipsStapling = skipsStapling
self.verifies = verifies
self.versionOverride = versionOverride
self.outputDirectory = outputDirectory
}
Expand Down
5 changes: 5 additions & 0 deletions swiftpkg/PackageBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ public struct PackageBuildCoordinator: @unchecked Sendable {
.notarize(package: context.output, configuration: notarizationConfig, skipsStapling: configuration.skipsStapling)
}
let signed = packageConfiguration.signing != nil && !configuration.skipsSigning
if configuration.verifies {
let notarized = packageConfiguration.notarization != nil && !configuration.skipsNotarization && !configuration.skipsSigning
try PackageVerifier(runner: runner, console: console)
.verify(package: output, expectedIdentifier: packageConfiguration.identifier, expectedVersion: packageConfiguration.version, signed: signed, notarized: notarized)
}
return BuildResult(
name: packageConfiguration.name,
version: packageConfiguration.version,
Expand Down
96 changes: 96 additions & 0 deletions swiftpkg/PackageVerifier.swift
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 }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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)\"."
Comment thread
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 }
}
}
1 change: 1 addition & 0 deletions swiftpkg/Support.swift
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ enum ToolPaths {
static let pkgutil = "/usr/sbin/pkgutil"
static let productbuild = "/usr/bin/productbuild"
static let xcrun = "/usr/bin/xcrun"
static let spctl = "/usr/sbin/spctl"
}

extension FileManager {
Expand Down
5 changes: 5 additions & 0 deletions swiftpkgCLI/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ public struct CLIOptions: ParsableArguments {
@Flag(name: .long, help: "Skip stapling after notarization.")
public var skipStapling = false

@Flag(name: .long, help: "After building, verify the package matches build-info: signature present when signing was requested (pkgutil), Gatekeeper-accepted when notarized (spctl). Fails the build on mismatch.")
public var verify = false

@Flag(name: .long, help: "Accepted for munki-pkg compatibility; ignored. swiftpkg never prompts to import into a repo, so there is nothing to skip.")
public var skipImport = false

Expand Down Expand Up @@ -108,6 +111,7 @@ public enum CLICommand {
skipsSigning: options.skipSigning,
skipsNotarization: options.skipNotarization,
skipsStapling: options.skipStapling,
verifies: options.verify,
versionOverride: options.pkgVersion,
outputDirectory: options.outputDir.map { URL(fileURLWithPath: $0).standardizedFileURL }
)
Expand Down Expand Up @@ -145,6 +149,7 @@ public enum CLIParser {
--skip-signing Skip configured package signing.
--skip-notarization Skip configured notarization.
--skip-stapling Skip stapling after notarization.
--verify Verify the built package matches build-info.
--output-format FORMAT Build result on stdout: text (default) or json.
--skip-import Accepted for munki-pkg compatibility; ignored.
--pkg-version VERSION Override the build-info version.
Expand Down
107 changes: 107 additions & 0 deletions swiftpkgTests/PackageVerifierTests.swift
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)
}
}
4 changes: 3 additions & 1 deletion swiftpkgTests/TestSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ final class RecordingRunner: ProcessRunning, @unchecked Sendable {

var calls: [Call] = []
var result = ProcessResult(status: 0, stdout: Data(), stderr: Data())
/// When set, chooses the result per call; falls back to `result` when nil.
var resultProvider: ((_ executable: String, _ arguments: [String]) -> ProcessResult)?
var onRun: ((String, [String]) throws -> Void)?

func run(executable: String, arguments: [String]) throws -> ProcessResult {
calls.append(Call(executable: executable, arguments: arguments))
try onRun?(executable, arguments)
return result
return resultProvider?(executable, arguments) ?? result
}
}

Expand Down