Skip to content

Commit 0e0964a

Browse files
authored
[Collections] API changes to support signed collections (#3201)
Motivation: Package collections may optionally be signed in the future ([forum post](https://forums.swift.org/t/package-collection-signing/43855)). Unsigned collections will require some changes to the UX (e.g., user needs to confirm their trust on unsigned collections), therefore the Package Collection APIs will need to be updated accordingly. Modifications: - Each package collection "source" in the config has a new `isTrusted` flag to record user's trust preference. - `addCollection` has a new `trustConfirmationProvider` closure that gets invoked when the newly added collection is unsigend. API client needs to provide user's trust preference when the closure is invoked. - New `updateCollection` API to update a collection's trust preference. This is for one-off config changes. - New `refreshCollection` API - `refreshCollections` already exists. It seems reasonable to allow refreshing individual collections. Result: APIs are ready to support signed (unsigned) collections.
1 parent d08a019 commit 0e0964a

10 files changed

+317
-40
lines changed

Sources/Commands/SwiftPackageCollectionsTool.swift

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

4-
Copyright 2020 Apple Inc. and the Swift project authors
4+
Copyright 2020-2021 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66

77
See http://swift.org/LICENSE.txt for license information
@@ -98,6 +98,9 @@ public struct SwiftPackageCollectionsTool: ParsableCommand {
9898

9999
@Option(name: .long, help: "Sort order for the added collection")
100100
var order: Int?
101+
102+
@Option(name: .long, help: "Trust the collection even if it is unsigned")
103+
var trustUnsigned: Bool?
101104

102105
mutating func run() throws {
103106
guard let collectionUrl = URL(string: collectionUrl) else {
@@ -106,7 +109,13 @@ public struct SwiftPackageCollectionsTool: ParsableCommand {
106109

107110
let source = PackageCollectionsModel.CollectionSource(type: .json, url: collectionUrl)
108111
let collection = try with { collections in
109-
return try tsc_await { collections.addCollection(source, order: order, callback: $0) }
112+
try tsc_await {
113+
collections.addCollection(
114+
source,
115+
order: order,
116+
trustConfirmationProvider: trustUnsigned.map { userTrusted in { _, callback in callback(userTrusted) } },
117+
callback: $0
118+
) }
110119
}
111120

112121
print("Added \"\(collection.name)\" to your package collections.")

Sources/PackageCollections/API.swift

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

4-
Copyright (c) 2020 Apple Inc. and the Swift project authors
4+
Copyright (c) 2020-2021 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66

77
See http://swift.org/LICENSE.txt for license information
@@ -12,7 +12,7 @@ import PackageModel
1212
import SourceControl
1313

1414
public protocol PackageCollectionsProtocol {
15-
// MARK: - Package set APIs
15+
// MARK: - Package collection APIs
1616

1717
/// Returns packages organized into collections.
1818
///
@@ -33,24 +33,36 @@ public protocol PackageCollectionsProtocol {
3333
/// - callback: The closure to invoke after triggering a refresh for the configured package collections.
3434
func refreshCollections(callback: @escaping (Result<[PackageCollectionsModel.CollectionSource], Error>) -> Void)
3535

36+
/// Refreshes a package collection.
37+
///
38+
/// - Parameters:
39+
/// - source: The package collection to be refreshed
40+
/// - callback: The closure to invoke with the refreshed `PackageCollection`
41+
func refreshCollection(
42+
_ source: PackageCollectionsModel.CollectionSource,
43+
callback: @escaping (Result<PackageCollectionsModel.Collection, Error>) -> Void
44+
)
45+
3646
/// Adds a package collection.
3747
///
3848
/// - Parameters:
3949
/// - source: The package collection's source
4050
/// - order: Optional. The order that the `PackageCollection` should take after being added to the list.
4151
/// By default the new collection is appended to the end (i.e., the least relevant order).
42-
/// - callback: The closure to invoke with the updated `PackageCollection`s
52+
/// - trustConfirmationProvider: The closure to invoke when the collection is not signed and user confirmation is required to proceed
53+
/// - callback: The closure to invoke with the newly added `PackageCollection`
4354
func addCollection(
4455
_ source: PackageCollectionsModel.CollectionSource,
4556
order: Int?,
57+
trustConfirmationProvider: ((PackageCollectionsModel.Collection, @escaping (Bool) -> Void) -> Void)?,
4658
callback: @escaping (Result<PackageCollectionsModel.Collection, Error>) -> Void
4759
)
4860

4961
/// Removes a package collection.
5062
///
5163
/// - Parameters:
5264
/// - source: The package collection's source
53-
/// - callback: The closure to invoke with the updated `PackageCollection`s
65+
/// - callback: The closure to invoke with the result becomes available
5466
func removeCollection(
5567
_ source: PackageCollectionsModel.CollectionSource,
5668
callback: @escaping (Result<Void, Error>) -> Void
@@ -59,15 +71,25 @@ public protocol PackageCollectionsProtocol {
5971
/// Moves a package collection to a different order.
6072
///
6173
/// - Parameters:
62-
/// - id: The identifier of the `PackageCollection` to be moved
74+
/// - source: The source of the `PackageCollection` to be reordered
6375
/// - order: The new order that the `PackageCollection` should be positioned after the move
64-
/// - callback: The closure to invoke with the updated `PackageCollection`s
76+
/// - callback: The closure to invoke with the result becomes available
6577
func moveCollection(
6678
_ source: PackageCollectionsModel.CollectionSource,
6779
to order: Int,
6880
callback: @escaping (Result<Void, Error>) -> Void
6981
)
7082

83+
/// Updates settings of a `PackageCollection` source (e.g., if it is trusted or not).
84+
///
85+
/// - Parameters:
86+
/// - source: The `PackageCollection` source to be updated
87+
/// - callback: The closure to invoke when result becomes available
88+
func updateCollection(
89+
_ source: PackageCollectionsModel.CollectionSource,
90+
callback: @escaping (Result<PackageCollectionsModel.Collection, Error>) -> Void
91+
)
92+
7193
/// Returns information about a package collection. The collection is not required to be in the configured list. If
7294
/// not found locally, the collection will be fetched from the source.
7395
///
@@ -145,3 +167,11 @@ public protocol PackageCollectionsProtocol {
145167
callback: @escaping (Result<PackageCollectionsModel.TargetSearchResult, Error>) -> Void
146168
)
147169
}
170+
171+
public enum PackageCollectionError: Equatable, Error {
172+
/// Package collection is not signed and there is no record of user's trust selection
173+
case trustConfirmationRequired
174+
175+
/// Package collection is not signed and user explicitly marks it untrusted
176+
case untrusted
177+
}

Sources/PackageCollections/Model/Collection.swift

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

4-
Copyright (c) 2020 Apple Inc. and the Swift project authors
4+
Copyright (c) 2020-2021 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66

77
See http://swift.org/LICENSE.txt for license information
@@ -54,6 +54,9 @@ extension PackageCollectionsModel {
5454
/// When this collection was last processed locally
5555
public let lastProcessedAt: Date
5656

57+
/// Indicates if the collection is signed
58+
public let isSigned: Bool
59+
5760
/// Initializes a `Collection`
5861
init(
5962
source: Source,
@@ -63,7 +66,8 @@ extension PackageCollectionsModel {
6366
packages: [Package],
6467
createdAt: Date,
6568
createdBy: Author?,
66-
lastProcessedAt: Date = Date()
69+
lastProcessedAt: Date = Date(),
70+
isSigned: Bool
6771
) {
6872
self.identifier = .init(from: source)
6973
self.source = source
@@ -74,6 +78,7 @@ extension PackageCollectionsModel {
7478
self.createdAt = createdAt
7579
self.createdBy = createdBy
7680
self.lastProcessedAt = lastProcessedAt
81+
self.isSigned = isSigned
7782
}
7883
}
7984
}
@@ -87,19 +92,27 @@ extension PackageCollectionsModel {
8792
/// URL of the source file
8893
public let url: URL
8994

95+
/// Indicates if the source is explicitly trusted or untrusted by the user
96+
public var isTrusted: Bool?
97+
9098
/// The source's absolute file system path, if its URL is of 'file' scheme.
9199
let absolutePath: AbsolutePath?
92100

93-
public init(type: CollectionSourceType, url: URL) {
101+
public init(type: CollectionSourceType, url: URL, isTrusted: Bool? = nil) {
94102
self.type = type
95103
self.url = url
104+
self.isTrusted = isTrusted
96105

97106
if url.scheme?.lowercased() == "file", let absolutePath = try? AbsolutePath(validating: url.path) {
98107
self.absolutePath = absolutePath
99108
} else {
100109
self.absolutePath = nil
101110
}
102111
}
112+
113+
public static func == (lhs: CollectionSource, rhs: CollectionSource) -> Bool {
114+
lhs.type == rhs.type && lhs.url == rhs.url
115+
}
103116
}
104117

105118
/// Represents the source type of a `Collection`

Sources/PackageCollections/PackageCollections.swift

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

4-
Copyright (c) 2020 Apple Inc. and the Swift project authors
4+
Copyright (c) 2020-2021 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66

77
See http://swift.org/LICENSE.txt for license information
@@ -103,7 +103,7 @@ public struct PackageCollections: PackageCollectionsProtocol {
103103
}
104104
let refreshResults = ThreadSafeArrayStore<Result<Model.Collection, Error>>()
105105
sources.forEach { source in
106-
self.refreshCollectionFromSource(source: source) { refreshResult in
106+
self.refreshCollectionFromSource(source: source, trustConfirmationProvider: nil) { refreshResult in
107107
refreshResults.append(refreshResult)
108108
if refreshResults.count == sources.count {
109109
let errors = refreshResults.compactMap { $0.failure }
@@ -115,8 +115,16 @@ public struct PackageCollections: PackageCollectionsProtocol {
115115
}
116116
}
117117

118+
public func refreshCollection(
119+
_ source: PackageCollectionsModel.CollectionSource,
120+
callback: @escaping (Result<PackageCollectionsModel.Collection, Error>) -> Void
121+
) {
122+
self.refreshCollectionFromSource(source: source, trustConfirmationProvider: nil, callback: callback)
123+
}
124+
118125
public func addCollection(_ source: PackageCollectionsModel.CollectionSource,
119126
order: Int? = nil,
127+
trustConfirmationProvider: ((PackageCollectionsModel.Collection, @escaping (Bool) -> Void) -> Void)? = nil,
120128
callback: @escaping (Result<PackageCollectionsModel.Collection, Error>) -> Void) {
121129
if let errors = source.validate()?.errors() {
122130
return callback(.failure(MultipleErrors(errors)))
@@ -129,7 +137,7 @@ public struct PackageCollections: PackageCollectionsProtocol {
129137
callback(.failure(error))
130138
case .success:
131139
// next try to fetch the collection from the network and store it locally so future operations dont need to access the network
132-
self.refreshCollectionFromSource(source: source, order: order, callback: callback)
140+
self.refreshCollectionFromSource(source: source, trustConfirmationProvider: trustConfirmationProvider, callback: callback)
133141
}
134142
}
135143
}
@@ -152,6 +160,18 @@ public struct PackageCollections: PackageCollectionsProtocol {
152160
self.storage.sources.move(source: source, to: order, callback: callback)
153161
}
154162

163+
public func updateCollection(_ source: PackageCollectionsModel.CollectionSource,
164+
callback: @escaping (Result<PackageCollectionsModel.Collection, Error>) -> Void) {
165+
self.storage.sources.update(source: source) { result in
166+
switch result {
167+
case .failure(let error):
168+
callback(.failure(error))
169+
case .success:
170+
self.refreshCollectionFromSource(source: source, trustConfirmationProvider: nil, callback: callback)
171+
}
172+
}
173+
}
174+
155175
// Returns information about a package collection.
156176
// The collection is not required to be in the configured list.
157177
// If not found locally (storage), the collection will be fetched from the source.
@@ -275,7 +295,7 @@ public struct PackageCollections: PackageCollectionsProtocol {
275295
// Fetch the collection from the network and store it in local storage
276296
// This helps avoid network access in normal operations
277297
private func refreshCollectionFromSource(source: PackageCollectionsModel.CollectionSource,
278-
order _: Int? = nil,
298+
trustConfirmationProvider: ((PackageCollectionsModel.Collection, @escaping (Bool) -> Void) -> Void)?,
279299
callback: @escaping (Result<Model.Collection, Error>) -> Void) {
280300
if let errors = source.validate()?.errors() {
281301
return callback(.failure(MultipleErrors(errors)))
@@ -288,7 +308,52 @@ public struct PackageCollections: PackageCollectionsProtocol {
288308
case .failure(let error):
289309
callback(.failure(error))
290310
case .success(let collection):
291-
self.storage.collections.put(collection: collection, callback: callback)
311+
// If collection is signed and signature is valid, save to storage. `provider.get`
312+
// would have failed if signature were invalid.
313+
if collection.isSigned {
314+
return self.storage.collections.put(collection: collection, callback: callback)
315+
}
316+
317+
// If collection is not signed, check if it's trusted by user and prompt user if needed.
318+
if let isTrusted = source.isTrusted {
319+
if isTrusted {
320+
return self.storage.collections.put(collection: collection, callback: callback)
321+
} else {
322+
// Try to remove the untrusted collection (if previously saved) from storage before calling back
323+
return self.storage.collections.remove(identifier: collection.identifier) { _ in
324+
callback(.failure(PackageCollectionError.untrusted))
325+
}
326+
}
327+
}
328+
329+
// No user preference recorded, so we need to prompt if we can.
330+
guard let trustConfirmationProvider = trustConfirmationProvider else {
331+
// Try to remove the untrusted collection (if previously saved) from storage before calling back
332+
return self.storage.collections.remove(identifier: collection.identifier) { _ in
333+
callback(.failure(PackageCollectionError.trustConfirmationRequired))
334+
}
335+
}
336+
337+
trustConfirmationProvider(collection) { userTrusted in
338+
var source = source
339+
source.isTrusted = userTrusted
340+
// Record user preference then save collection to storage
341+
self.storage.sources.update(source: source) { updateSourceResult in
342+
switch updateSourceResult {
343+
case .failure(let error):
344+
callback(.failure(error))
345+
case .success:
346+
if userTrusted {
347+
self.storage.collections.put(collection: collection, callback: callback)
348+
} else {
349+
// Try to remove the untrusted collection (if previously saved) from storage before calling back
350+
return self.storage.collections.remove(identifier: collection.identifier) { _ in
351+
callback(.failure(PackageCollectionError.untrusted))
352+
}
353+
}
354+
}
355+
}
356+
}
292357
}
293358
}
294359
}

Sources/PackageCollections/Providers/JSONPackageCollectionProvider.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,13 @@ struct JSONPackageCollectionProvider: PackageCollectionProvider {
108108
}
109109

110110
private func makeCollection(from collection: JSONModel.Collection, source: Model.CollectionSource) -> Result<Model.Collection, Error> {
111+
// TODO: Check collection's signature
112+
// 1. If signed and signature is
113+
// a. valid: process the collection; set isSigned=true
114+
// b. invalid: includes expired cert, untrusted cert, signature-payload mismatch => return error
115+
// 2. If unsigned, process the collection; set isSigned=false.
116+
let isSigned = true
117+
111118
var serializationOkay = true
112119
let packages = collection.packages.map { package -> Model.Package in
113120
let versions = package.versions.compactMap { version -> Model.Package.Version? in
@@ -170,7 +177,8 @@ struct JSONPackageCollectionProvider: PackageCollectionProvider {
170177
packages: packages,
171178
createdAt: collection.generatedAt,
172179
createdBy: collection.generatedBy.flatMap { Model.Collection.Author(name: $0.name) },
173-
lastProcessedAt: Date()))
180+
lastProcessedAt: Date(),
181+
isSigned: isSigned))
174182
}
175183

176184
private func makeRequestOptions(validResponseCodes: [Int]) -> HTTPClientRequest.Options {

0 commit comments

Comments
 (0)