-
Notifications
You must be signed in to change notification settings - Fork 1
Add --provenance attestation sidecar #25
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
jordancalhoun
merged 3 commits into
codecarton:next
from
rodchristiansen:feat/provenance
Aug 2, 2026
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
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,132 @@ | ||
| import CryptoKit | ||
| import Foundation | ||
|
|
||
| /// Build attestation written next to the package as `<pkg>.provenance.json`. | ||
| public struct Provenance: Codable, Sendable, Equatable { | ||
| public let tool: String | ||
| public let toolVersion: String | ||
| public let builtAt: String | ||
| public let name: String | ||
| public let version: String | ||
| public let identifier: String | ||
| public let pkgPath: String | ||
| public let sha256: String | ||
| public let inputDigest: String | ||
| public let gitCommit: String? | ||
| public let gitRemote: String? | ||
|
|
||
| enum CodingKeys: String, CodingKey { | ||
| case tool | ||
| case toolVersion = "tool_version" | ||
| case builtAt = "built_at" | ||
| case name, version, identifier | ||
| case pkgPath = "pkg_path" | ||
| case sha256 | ||
| case inputDigest = "input_digest" | ||
| case gitCommit = "git_commit" | ||
| case gitRemote = "git_remote" | ||
| } | ||
|
|
||
| public func jsonString() throws -> String { | ||
| let encoder = JSONEncoder() | ||
| encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] | ||
| return String(decoding: try encoder.encode(self), as: UTF8.self) | ||
| } | ||
| } | ||
|
|
||
| /// Assembles a `Provenance` from the project, its inputs, and git metadata. | ||
| public struct ProvenanceBuilder { | ||
| private let runner: any ProcessRunning | ||
| private let fileManager: FileManager | ||
|
|
||
| public init(runner: any ProcessRunning, fileManager: FileManager = .default) { | ||
| self.runner = runner | ||
| self.fileManager = fileManager | ||
| } | ||
|
|
||
| public func build(configuration: PackageConfiguration, output: URL, project: URL, now: Date = Date()) throws -> Provenance { | ||
| let formatter = ISO8601DateFormatter() | ||
| formatter.formatOptions = [.withInternetDateTime] | ||
| return Provenance( | ||
| tool: "swiftpkg", | ||
| toolVersion: swiftpkgVersion, | ||
| builtAt: formatter.string(from: now), | ||
| name: configuration.name, | ||
| version: configuration.version, | ||
| identifier: configuration.identifier, | ||
| pkgPath: output.path, | ||
| sha256: try provenanceSHA256(ofFileAt: output), | ||
| inputDigest: try inputDigest(for: project), | ||
| gitCommit: gitOutput(["-C", project.path, "rev-parse", "HEAD"], in: project), | ||
| gitRemote: gitOutput(["-C", project.path, "remote", "get-url", "origin"], in: project).map(Self.sanitizedRemote) | ||
| ) | ||
| } | ||
|
|
||
| /// Deterministic digest of the build inputs (payload, scripts, build-info), | ||
| /// hashing each file's project-relative path and contents in sorted order. | ||
| private func inputDigest(for project: URL) throws -> String { | ||
| var entries: [(path: String, url: URL)] = [] | ||
| for subdirectory in ["payload", "scripts"] { | ||
| let directory = project.appendingPathComponent(subdirectory, isDirectory: true) | ||
| guard fileManager.directoryExists(at: directory) else { continue } | ||
| guard let enumerator = fileManager.enumerator(at: directory, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey]) else { continue } | ||
| for case let fileURL as URL in enumerator { | ||
| let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) | ||
| guard values?.isRegularFile == true || values?.isSymbolicLink == true else { continue } | ||
| entries.append((relativePath(of: fileURL, under: project), fileURL)) | ||
| } | ||
| } | ||
| for name in ["build-info.plist", "build-info.json", "build-info.yaml", "build-info.yml"] { | ||
| let url = project.appendingPathComponent(name) | ||
| if fileManager.fileExists(atPath: url.path) { entries.append((name, url)) } | ||
| } | ||
| entries.sort { $0.path < $1.path } | ||
|
|
||
| var hasher = SHA256() | ||
| for entry in entries { | ||
| hasher.update(data: Data(entry.path.utf8)) | ||
| hasher.update(data: Data([0])) | ||
| let mode = (try fileManager.attributesOfItem(atPath: entry.url.path)[.posixPermissions] as? NSNumber)?.uint16Value ?? 0 | ||
| hasher.update(data: withUnsafeBytes(of: (mode & 0o7777).littleEndian) { Data($0) }) | ||
| if let destination = try? fileManager.destinationOfSymbolicLink(atPath: entry.url.path) { | ||
| hasher.update(data: Data(destination.utf8)) | ||
| } else { | ||
| hasher.update(data: try Data(contentsOf: entry.url)) | ||
| } | ||
| } | ||
| return hasher.finalize().map { String(format: "%02x", $0) }.joined() | ||
| } | ||
|
|
||
| private func relativePath(of url: URL, under project: URL) -> String { | ||
| let base = project.standardizedFileURL.path | ||
| let path = url.standardizedFileURL.path | ||
| return path.hasPrefix(base + "/") ? String(path.dropFirst(base.count + 1)) : url.lastPathComponent | ||
| } | ||
|
|
||
| private func gitOutput(_ arguments: [String], in project: URL) -> String? { | ||
| guard let result = try? runner.run(executable: ToolPaths.git, arguments: arguments), result.status == 0 else { return nil } | ||
| let trimmed = result.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| return trimmed.isEmpty ? nil : trimmed | ||
| } | ||
|
|
||
| /// Removes `user:pass@` userinfo from a remote URL before recording it. | ||
| static func sanitizedRemote(_ remote: String) -> String { | ||
| guard let schemeRange = remote.range(of: "://") else { return remote } | ||
| let authorityAndPath = remote[schemeRange.upperBound...] | ||
| guard let at = authorityAndPath.firstIndex(of: "@") else { return remote } | ||
| let firstSlash = authorityAndPath.firstIndex(of: "/") ?? authorityAndPath.endIndex | ||
| guard at < firstSlash else { return remote } | ||
| return String(remote[..<schemeRange.upperBound]) + String(authorityAndPath[authorityAndPath.index(after: at)...]) | ||
| } | ||
| } | ||
|
|
||
| /// Streaming SHA-256 of a file, lowercase hex. | ||
| func provenanceSHA256(ofFileAt url: URL) throws -> String { | ||
| let handle = try FileHandle(forReadingFrom: url) | ||
| defer { try? handle.close() } | ||
| var hasher = SHA256() | ||
| while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty { | ||
| hasher.update(data: chunk) | ||
| } | ||
| return hasher.finalize().map { String(format: "%02x", $0) }.joined() | ||
| } | ||
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,129 @@ | ||
| import Foundation | ||
| import Testing | ||
| @testable import SwiftPkgCore | ||
|
|
||
| private final class GitRunner: ProcessRunning, @unchecked Sendable { | ||
| var commit = "abc123" | ||
| var remote = "https://github.com/example/repo.git" | ||
|
|
||
| func run(executable: String, arguments: [String]) throws -> ProcessResult { | ||
| let out: String | ||
| if arguments.contains("rev-parse") { out = commit } | ||
| else if arguments.contains("remote") { out = remote } | ||
| else { out = "" } | ||
| return ProcessResult(status: 0, stdout: Data((out + "\n").utf8), stderr: Data()) | ||
| } | ||
| } | ||
|
|
||
| struct ProvenanceTests { | ||
|
|
||
| @Test("sanitizedRemote strips user:pass@ userinfo but leaves clean URLs") | ||
| func sanitizesRemote() { | ||
| #expect(ProvenanceBuilder.sanitizedRemote("https://user:pass@github.com/x/y.git") == "https://github.com/x/y.git") | ||
| #expect(ProvenanceBuilder.sanitizedRemote("https://token@github.com/x/y.git") == "https://github.com/x/y.git") | ||
| #expect(ProvenanceBuilder.sanitizedRemote("https://github.com/x/y.git") == "https://github.com/x/y.git") | ||
| #expect(ProvenanceBuilder.sanitizedRemote("git@github.com:x/y.git") == "git@github.com:x/y.git") // scp-style, no :// | ||
| } | ||
|
|
||
| @Test("provenance captures git metadata and a stable input digest") | ||
| func buildsProvenance() throws { | ||
| let temp = try TemporaryDirectory() | ||
| defer { temp.remove() } | ||
| let project = temp.url.appendingPathComponent("P", isDirectory: true) | ||
| let payload = project.appendingPathComponent("payload", isDirectory: true) | ||
| try FileManager.default.createDirectory(at: payload, withIntermediateDirectories: true) | ||
| try write("hello", to: payload.appendingPathComponent("file.txt")) | ||
| try write(#"{"name":"App-1.0.pkg","identifier":"com.example.app","version":"1.0"}"#, to: project.appendingPathComponent("build-info.json")) | ||
| let output = project.appendingPathComponent("build/App-1.0.pkg") | ||
| try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) | ||
| try write("PKGDATA", to: output) | ||
|
|
||
| let runner = GitRunner() | ||
| runner.remote = "https://user:secret@github.com/example/repo.git" | ||
| let builder = ProvenanceBuilder(runner: runner, fileManager: .default) | ||
| let config = try BuildInfoStore.load(from: project, requestedFormat: nil) | ||
| let now = Date(timeIntervalSince1970: 1_800_000_000) | ||
| let provenance = try builder.build(configuration: config, output: output, project: project, now: now) | ||
|
|
||
| #expect(provenance.tool == "swiftpkg") | ||
| #expect(provenance.gitCommit == "abc123") | ||
| #expect(provenance.gitRemote == "https://github.com/example/repo.git") // credentials stripped | ||
| #expect(provenance.identifier == "com.example.app") | ||
| #expect(provenance.sha256.count == 64) | ||
| #expect(provenance.inputDigest.count == 64) | ||
|
|
||
| // Input digest is deterministic for identical inputs. | ||
| let again = try builder.build(configuration: config, output: output, project: project, now: now) | ||
| #expect(again.inputDigest == provenance.inputDigest) | ||
|
|
||
| // JSON uses snake_case keys and round-trips. | ||
| let json = try provenance.jsonString() | ||
| #expect(json.contains("\"input_digest\"")) | ||
| #expect(json.contains("\"git_commit\"")) | ||
| let decoded = try JSONDecoder().decode(Provenance.self, from: Data(json.utf8)) | ||
| #expect(decoded == provenance) | ||
| } | ||
|
|
||
| @Test("input digest changes when an input file changes") | ||
| func digestChangesWithInputs() throws { | ||
| let temp = try TemporaryDirectory() | ||
| defer { temp.remove() } | ||
| let project = temp.url.appendingPathComponent("P", isDirectory: true) | ||
| let payload = project.appendingPathComponent("payload", isDirectory: true) | ||
| try FileManager.default.createDirectory(at: payload, withIntermediateDirectories: true) | ||
| try write(#"{"name":"App-1.0.pkg","identifier":"com.example.app","version":"1.0"}"#, to: project.appendingPathComponent("build-info.json")) | ||
| let output = project.appendingPathComponent("build/App-1.0.pkg") | ||
| try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) | ||
| try write("PKG", to: output) | ||
| let builder = ProvenanceBuilder(runner: GitRunner(), fileManager: .default) | ||
| let config = try BuildInfoStore.load(from: project, requestedFormat: nil) | ||
|
|
||
| try write("v1", to: payload.appendingPathComponent("file.txt")) | ||
| let first = try builder.build(configuration: config, output: output, project: project).inputDigest | ||
| try write("v2", to: payload.appendingPathComponent("file.txt")) | ||
| let second = try builder.build(configuration: config, output: output, project: project).inputDigest | ||
| #expect(first != second) | ||
| } | ||
|
|
||
| private func makeDigestFixture() throws -> (TemporaryDirectory, URL, URL, ProvenanceBuilder, PackageConfiguration) { | ||
| let temp = try TemporaryDirectory() | ||
| let project = temp.url.appendingPathComponent("P", isDirectory: true) | ||
| let payload = project.appendingPathComponent("payload", isDirectory: true) | ||
| try FileManager.default.createDirectory(at: payload, withIntermediateDirectories: true) | ||
| try write(#"{"name":"App-1.0.pkg","identifier":"com.example.app","version":"1.0"}"#, to: project.appendingPathComponent("build-info.json")) | ||
| let output = project.appendingPathComponent("build/App-1.0.pkg") | ||
| try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) | ||
| try write("PKG", to: output) | ||
| let builder = ProvenanceBuilder(runner: GitRunner(), fileManager: .default) | ||
| let config = try BuildInfoStore.load(from: project, requestedFormat: nil) | ||
| return (temp, project, payload, builder, config) | ||
| } | ||
|
|
||
| @Test("input digest changes when a file's executable bit is toggled") | ||
| func digestChangesWithPermissions() throws { | ||
| let (temp, project, payload, builder, config) = try makeDigestFixture() | ||
| defer { temp.remove() } | ||
| let script = payload.appendingPathComponent("run.sh") | ||
| try write("#!/bin/sh\n", to: script) | ||
| try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: script.path) | ||
| let before = try builder.build(configuration: config, output: output(for: project), project: project).inputDigest | ||
| try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: script.path) | ||
| let after = try builder.build(configuration: config, output: output(for: project), project: project).inputDigest | ||
| #expect(before != after) | ||
| } | ||
|
|
||
| @Test("input digest changes when a symlink's target changes") | ||
| func digestChangesWithSymlinkTarget() throws { | ||
| let (temp, project, payload, builder, config) = try makeDigestFixture() | ||
| defer { temp.remove() } | ||
| let link = payload.appendingPathComponent("Current") | ||
| try FileManager.default.createSymbolicLink(atPath: link.path, withDestinationPath: "A") | ||
| let before = try builder.build(configuration: config, output: output(for: project), project: project).inputDigest | ||
| try FileManager.default.removeItem(at: link) | ||
| try FileManager.default.createSymbolicLink(atPath: link.path, withDestinationPath: "B") | ||
| let after = try builder.build(configuration: config, output: output(for: project), project: project).inputDigest | ||
| #expect(before != after) | ||
| } | ||
|
|
||
| private func output(for project: URL) -> URL { project.appendingPathComponent("build/App-1.0.pkg") } | ||
| } |
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.