Skip to content

Commit 48192bb

Browse files
authored
Add PackageSigningEntityStorage (#6188)
Motivation: A signing entity is one that signs a package. Per the [proposal](swiftlang/swift-evolution#1925), SwiftPM can perform additional TOFU to ensure different versions of a package are signed by the same entity. `PackageSigningEntityStorage` will be used to store data for this purpose.
1 parent fd91c7b commit 48192bb

5 files changed

Lines changed: 350 additions & 2 deletions

File tree

Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ let package = Package(
294294
name: "PackageSigning",
295295
dependencies: [
296296
"Basics",
297+
"PackageModel",
297298
]
298299
),
299300

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2023 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See http://swift.org/LICENSE.txt for license information
9+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import Basics
14+
import Dispatch
15+
import Foundation
16+
import PackageModel
17+
import TSCBasic
18+
19+
import struct TSCUtility.Version
20+
21+
public struct FilePackageSigningEntityStorage: PackageSigningEntityStorage {
22+
let fileSystem: FileSystem
23+
let directoryPath: AbsolutePath
24+
25+
private let encoder: JSONEncoder
26+
private let decoder: JSONDecoder
27+
28+
public init(fileSystem: FileSystem, directoryPath: AbsolutePath) {
29+
self.fileSystem = fileSystem
30+
self.directoryPath = directoryPath
31+
32+
self.encoder = JSONEncoder.makeWithDefaults()
33+
self.decoder = JSONDecoder.makeWithDefaults()
34+
}
35+
36+
public func get(
37+
package: PackageIdentity,
38+
observabilityScope: ObservabilityScope,
39+
callbackQueue: DispatchQueue,
40+
callback: @escaping (Result<[SigningEntity: Set<Version>], Error>) -> Void
41+
) {
42+
let callback = self.makeAsync(callback, on: callbackQueue)
43+
44+
do {
45+
let signedVersions = try self.withLock {
46+
try self.loadFromDisk(package: package)
47+
}
48+
callback(.success(signedVersions))
49+
} catch {
50+
callback(.failure(error))
51+
}
52+
}
53+
54+
public func put(
55+
package: PackageIdentity,
56+
version: Version,
57+
signingEntity: SigningEntity,
58+
observabilityScope: ObservabilityScope,
59+
callbackQueue: DispatchQueue,
60+
callback: @escaping (Result<Void, Error>) -> Void
61+
) {
62+
let callback = self.makeAsync(callback, on: callbackQueue)
63+
64+
do {
65+
try self.withLock {
66+
var signedVersions = try self.loadFromDisk(package: package)
67+
68+
if let existing = signedVersions.signingEntity(of: version) {
69+
// Error if we try to write a different signing entity for a version
70+
guard signingEntity == existing else {
71+
throw PackageSigningEntityStorageError.conflict(
72+
package: package,
73+
version: version,
74+
given: signingEntity,
75+
existing: existing
76+
)
77+
}
78+
// Don't need to do anything if signing entities are the same
79+
return
80+
}
81+
82+
var versions = signedVersions.removeValue(forKey: signingEntity) ?? []
83+
versions.insert(version)
84+
signedVersions[signingEntity] = versions
85+
86+
try self.saveToDisk(package: package, signedVersions: signedVersions)
87+
}
88+
callback(.success(()))
89+
} catch {
90+
callback(.failure(error))
91+
}
92+
}
93+
94+
private func loadFromDisk(package: PackageIdentity) throws -> [SigningEntity: Set<Version>] {
95+
let path = self.directoryPath.appending(component: package.signedVersionsFilename)
96+
97+
guard self.fileSystem.exists(path) else {
98+
return [:]
99+
}
100+
101+
let data: Data = try fileSystem.readFileContents(path)
102+
guard data.count > 0 else {
103+
return [:]
104+
}
105+
106+
let container = try self.decoder.decode(StorageModel.Container.self, from: data)
107+
return try container.signedVersionsByEntity()
108+
}
109+
110+
private func saveToDisk(package: PackageIdentity, signedVersions: [SigningEntity: Set<Version>]) throws {
111+
if !self.fileSystem.exists(self.directoryPath) {
112+
try self.fileSystem.createDirectory(self.directoryPath, recursive: true)
113+
}
114+
115+
let container = try StorageModel.Container(signedVersions)
116+
let buffer = try encoder.encode(container)
117+
118+
let path = self.directoryPath.appending(component: package.signedVersionsFilename)
119+
try self.fileSystem.writeFileContents(path, bytes: ByteString(buffer))
120+
}
121+
122+
private func withLock<T>(_ body: () throws -> T) throws -> T {
123+
if !self.fileSystem.exists(self.directoryPath) {
124+
try self.fileSystem.createDirectory(self.directoryPath, recursive: true)
125+
}
126+
return try self.fileSystem.withLock(on: self.directoryPath, type: .exclusive, body)
127+
}
128+
129+
private func makeAsync<T>(
130+
_ closure: @escaping (Result<T, Error>) -> Void,
131+
on queue: DispatchQueue
132+
) -> (Result<T, Error>) -> Void {
133+
{ result in queue.async { closure(result) } }
134+
}
135+
}
136+
137+
private enum StorageModel {
138+
struct Container: Codable {
139+
let signedVersions: [SignedVersions]
140+
141+
init(_ signedVersionsByEntity: [SigningEntity: Set<Version>]) throws {
142+
self.signedVersions = signedVersionsByEntity
143+
.map { SignedVersions(signingEntity: $0.key, versions: $0.value) }
144+
}
145+
146+
func signedVersionsByEntity() throws -> [SigningEntity: Set<Version>] {
147+
try Dictionary(throwingUniqueKeysWithValues: self.signedVersions.map {
148+
($0.signingEntity, $0.versions)
149+
})
150+
}
151+
}
152+
153+
struct SignedVersions: Codable {
154+
let signingEntity: SigningEntity
155+
let versions: Set<Version>
156+
}
157+
}
158+
159+
extension PackageIdentity {
160+
var signedVersionsFilename: String {
161+
"\(self.description).json"
162+
}
163+
}
164+
165+
extension Dictionary where Key == SigningEntity, Value == Set<Version> {
166+
fileprivate func signingEntity(of version: Version) -> SigningEntity? {
167+
for (signingEntity, versions) in self {
168+
if versions.contains(version) {
169+
return signingEntity
170+
}
171+
}
172+
return nil
173+
}
174+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2023 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See http://swift.org/LICENSE.txt for license information
9+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import Basics
14+
import Dispatch
15+
import PackageModel
16+
17+
import struct TSCUtility.Version
18+
19+
public protocol PackageSigningEntityStorage {
20+
/// For a given package, return the signing entities and the package versions that each of them signed.
21+
func get(
22+
package: PackageIdentity,
23+
observabilityScope: ObservabilityScope,
24+
callbackQueue: DispatchQueue,
25+
callback: @escaping (Result<[SigningEntity: Set<Version>], Error>) -> Void
26+
)
27+
28+
/// Record signing entity for a given package version.
29+
func put(
30+
package: PackageIdentity,
31+
version: Version,
32+
signingEntity: SigningEntity,
33+
observabilityScope: ObservabilityScope,
34+
callbackQueue: DispatchQueue,
35+
callback: @escaping (Result<Void, Error>) -> Void
36+
)
37+
}
38+
39+
public enum PackageSigningEntityStorageError: Error, Equatable, CustomStringConvertible {
40+
case conflict(package: PackageIdentity, version: Version, given: SigningEntity, existing: SigningEntity)
41+
42+
public var description: String {
43+
switch self {
44+
case .conflict(let package, let version, let given, let existing):
45+
return "\(package)@\(version) was previously signed by \(existing), which is different from \(given)."
46+
}
47+
}
48+
}

Sources/PackageSigning/SigningEntity.swift renamed to Sources/PackageSigning/SigningEntity/SigningEntity.swift

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import Security
1818

1919
// MARK: - SigningEntity is the entity that generated the signature
2020

21-
public struct SigningEntity {
21+
public struct SigningEntity: Hashable, Codable, CustomStringConvertible {
2222
public let type: SigningEntityType?
2323
public let name: String?
2424
public let organizationalUnit: String?
@@ -28,6 +28,18 @@ public struct SigningEntity {
2828
self.type != nil
2929
}
3030

31+
public init(
32+
type: SigningEntityType?,
33+
name: String?,
34+
organizationalUnit: String?,
35+
organization: String?
36+
) {
37+
self.type = type
38+
self.name = name
39+
self.organizationalUnit = organizationalUnit
40+
self.organization = organization
41+
}
42+
3143
public init(of signature: Data, signatureFormat: SignatureFormat) throws {
3244
let provider = signatureFormat.provider
3345
self = try provider.signingEntity(of: signature)
@@ -62,11 +74,15 @@ public struct SigningEntity {
6274
// TODO: extract id, name, organization, etc. from cert
6375
fatalError("TO BE IMPLEMENTED")
6476
}
77+
78+
public var description: String {
79+
"SigningEntity[type=\(String(describing: self.type)), name=\(String(describing: self.name)), organizationalUnit=\(String(describing: self.organizationalUnit)), organization=\(String(describing: self.organization))]"
80+
}
6581
}
6682

6783
// MARK: - SigningEntity types that SwiftPM recognizes
6884

69-
public enum SigningEntityType {
85+
public enum SigningEntityType: String, Hashable, Codable {
7086
case adp // Apple Developer Program
7187

7288
static let oid_adpSwiftPackageMarker = "1.2.840.113635.100.6.1.35"
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2023 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See http://swift.org/LICENSE.txt for license information
9+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import Basics
14+
import PackageModel
15+
@testable import PackageSigning
16+
import SPMTestSupport
17+
import TSCBasic
18+
import XCTest
19+
20+
import struct TSCUtility.Version
21+
22+
final class FilePackageSigningEntityStorageTests: XCTestCase {
23+
func testHappyCase() throws {
24+
let mockFileSystem = InMemoryFileSystem()
25+
let directoryPath = AbsolutePath(path: "/signing")
26+
let storage = FilePackageSigningEntityStorage(fileSystem: mockFileSystem, directoryPath: directoryPath)
27+
28+
// Record signing entities for mona.LinkedList
29+
let package = PackageIdentity.plain("mona.LinkedList")
30+
let appleseed = SigningEntity(type: nil, name: "J. Appleseed", organizationalUnit: nil, organization: nil)
31+
let davinci = SigningEntity(type: nil, name: "L. da Vinci", organizationalUnit: nil, organization: nil)
32+
try storage.put(package: package, version: Version("1.0.0"), signingEntity: davinci)
33+
try storage.put(package: package, version: Version("1.1.0"), signingEntity: davinci)
34+
try storage.put(package: package, version: Version("2.0.0"), signingEntity: appleseed)
35+
// Record signing entity for another package
36+
let otherPackage = PackageIdentity.plain("other.LinkedList")
37+
try storage.put(package: otherPackage, version: Version("1.0.0"), signingEntity: appleseed)
38+
39+
// A data file should have been created for each package
40+
XCTAssertTrue(mockFileSystem.exists(storage.directoryPath.appending(component: package.signedVersionsFilename)))
41+
XCTAssertTrue(
42+
mockFileSystem
43+
.exists(storage.directoryPath.appending(component: otherPackage.signedVersionsFilename))
44+
)
45+
46+
// Signed versions should be saved
47+
do {
48+
let signedVersions = try storage.get(package: package)
49+
XCTAssertEqual(signedVersions.count, 2)
50+
XCTAssertEqual(signedVersions[davinci], [Version("1.0.0"), Version("1.1.0")])
51+
XCTAssertEqual(signedVersions[appleseed], [Version("2.0.0")])
52+
}
53+
54+
do {
55+
let signedVersions = try storage.get(package: otherPackage)
56+
XCTAssertEqual(signedVersions.count, 1)
57+
XCTAssertEqual(signedVersions[appleseed], [Version("1.0.0")])
58+
}
59+
}
60+
61+
func testSingleFingerprintPerKind() throws {
62+
let mockFileSystem = InMemoryFileSystem()
63+
let directoryPath = AbsolutePath(path: "/signing")
64+
let storage = FilePackageSigningEntityStorage(fileSystem: mockFileSystem, directoryPath: directoryPath)
65+
66+
let package = PackageIdentity.plain("mona.LinkedList")
67+
let appleseed = SigningEntity(type: nil, name: "J. Appleseed", organizationalUnit: nil, organization: nil)
68+
let davinci = SigningEntity(type: nil, name: "L. da Vinci", organizationalUnit: nil, organization: nil)
69+
let version = Version("1.0.0")
70+
try storage.put(package: package, version: version, signingEntity: davinci)
71+
72+
// Writing different signing entities for the same version should fail
73+
XCTAssertThrowsError(try storage.put(package: package, version: version, signingEntity: appleseed)) { error in
74+
guard case PackageSigningEntityStorageError.conflict = error else {
75+
return XCTFail("Expected PackageSigningEntityStorageError.conflict, got \(error)")
76+
}
77+
}
78+
}
79+
}
80+
81+
extension PackageSigningEntityStorage {
82+
fileprivate func get(package: PackageIdentity) throws -> [SigningEntity: Set<Version>] {
83+
try tsc_await {
84+
self.get(
85+
package: package,
86+
observabilityScope: ObservabilitySystem.NOOP,
87+
callbackQueue: .sharedConcurrent,
88+
callback: $0
89+
)
90+
}
91+
}
92+
93+
fileprivate func put(
94+
package: PackageIdentity,
95+
version: Version,
96+
signingEntity: SigningEntity
97+
) throws {
98+
try tsc_await {
99+
self.put(
100+
package: package,
101+
version: version,
102+
signingEntity: signingEntity,
103+
observabilityScope: ObservabilitySystem.NOOP,
104+
callbackQueue: .sharedConcurrent,
105+
callback: $0
106+
)
107+
}
108+
}
109+
}

0 commit comments

Comments
 (0)