Skip to content

Commit f040400

Browse files
authored
Package fingerprint storage (#3879)
Motivation: Provide trust-on-first-use (TOFU) security model for package dependencies. SwiftPM will record locally the "fingerprint" of a package version when it is first downloaded and ensure the fingerprint remain the same in subsequent downloads. For package downloaded from registry, the fingerprint is the source archive checksum. For source control, it is the git revision. This PR only adds storage APIs for writing and reading package fingerprints and an implementation that uses local file system. The integration of fingerprint storage into the download workflow(s), i.e. TOFU implementation, will come in a separate PR. Modifications: - Add `PackageFingerprint` module. - Add `PackageFingerprintStorage` protocol that defines APIs for reading and writing fingerprints. - Add `FilePackageFingerprintStorage` which is an implementation based on file system.
1 parent e2dc7b6 commit f040400

File tree

8 files changed

+474
-8
lines changed

8 files changed

+474
-8
lines changed

Package.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,15 @@ let package = Package(
258258
],
259259
exclude: ["CMakeLists.txt"]
260260
),
261+
262+
.target(
263+
name: "PackageFingerprint",
264+
dependencies: [
265+
"Basics",
266+
"PackageModel",
267+
],
268+
exclude: ["CMakeLists.txt"]
269+
),
261270

262271
// MARK: Package Manager Functionality
263272

@@ -484,6 +493,10 @@ let package = Package(
484493
name: "PackageCollectionsTests",
485494
dependencies: ["PackageCollections", "SPMTestSupport"]
486495
),
496+
.testTarget(
497+
name: "PackageFingerprintTests",
498+
dependencies: ["PackageFingerprint", "SPMTestSupport"]
499+
),
487500
.testTarget(
488501
name: "PackageRegistryTests",
489502
dependencies: ["SPMTestSupport", "PackageRegistry"]

Sources/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ add_subdirectory(PackageCollectionsModel)
1515
add_subdirectory(PackageCollectionsSigning)
1616
add_subdirectory(PackageCollectionsSigningLibc)
1717
add_subdirectory(PackageDescription)
18+
add_subdirectory(PackageFingerprint)
1819
add_subdirectory(PackageGraph)
1920
add_subdirectory(PackageLoading)
2021
add_subdirectory(PackageModel)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# This source file is part of the Swift.org open source project
2+
#
3+
# Copyright (c) 2021 Apple Inc. and the Swift project authors
4+
# Licensed under Apache License v2.0 with Runtime Library Exception
5+
#
6+
# See http://swift.org/LICENSE.txt for license information
7+
# See http://swift.org/CONTRIBUTORS.txt for Swift project authors
8+
9+
add_library(PackageFingerprint
10+
FilePackageFingerprintStorage.swift
11+
Model.swift
12+
PackageFingerprintStorage.swift)
13+
target_link_libraries(PackageFingerprint PUBLIC
14+
Basics
15+
PackageModel
16+
TSCBasic
17+
TSCUtility)
18+
# NOTE(compnerd) workaround for CMake not setting up include flags yet
19+
set_target_properties(PackageFingerprint PROPERTIES
20+
INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_Swift_MODULE_DIRECTORY})
21+
22+
if(USE_CMAKE_INSTALL)
23+
install(TARGETS PackageFingerprint
24+
ARCHIVE DESTINATION lib
25+
LIBRARY DESTINATION lib
26+
RUNTIME DESTINATION bin)
27+
endif()
28+
set_property(GLOBAL APPEND PROPERTY SwiftPM_EXPORTS PackageFingerprint)
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2021 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See http://swift.org/LICENSE.txt for license information
8+
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import Basics
12+
import Dispatch
13+
import Foundation
14+
import PackageModel
15+
import TSCBasic
16+
import TSCUtility
17+
18+
public struct FilePackageFingerprintStorage: PackageFingerprintStorage {
19+
let fileSystem: FileSystem
20+
let directoryPath: AbsolutePath
21+
22+
private let encoder: JSONEncoder
23+
private let decoder: JSONDecoder
24+
25+
init(fileSystem: FileSystem, directoryPath: AbsolutePath) {
26+
self.fileSystem = fileSystem
27+
self.directoryPath = directoryPath
28+
29+
self.encoder = JSONEncoder.makeWithDefaults()
30+
self.decoder = JSONDecoder.makeWithDefaults()
31+
}
32+
33+
public func get(package: PackageIdentity,
34+
version: Version,
35+
observabilityScope: ObservabilityScope,
36+
callbackQueue: DispatchQueue,
37+
callback: @escaping (Result<[Fingerprint.Kind: Fingerprint], Error>) -> Void)
38+
{
39+
let callback = self.makeAsync(callback, on: callbackQueue)
40+
41+
do {
42+
let packageFingerprints = try self.withLock {
43+
try self.loadFromDisk(package: package)
44+
}
45+
46+
guard let fingerprints = packageFingerprints[version] else {
47+
throw PackageFingerprintStorageError.notFound
48+
}
49+
50+
callback(.success(fingerprints))
51+
} catch {
52+
callback(.failure(error))
53+
}
54+
}
55+
56+
public func put(package: PackageIdentity,
57+
version: Version,
58+
fingerprint: Fingerprint,
59+
observabilityScope: ObservabilityScope,
60+
callbackQueue: DispatchQueue,
61+
callback: @escaping (Result<Void, Error>) -> Void)
62+
{
63+
let callback = self.makeAsync(callback, on: callbackQueue)
64+
65+
do {
66+
try self.withLock {
67+
var packageFingerprints = try self.loadFromDisk(package: package)
68+
69+
if let existing = packageFingerprints[version]?[fingerprint.origin.kind] {
70+
// Error if we try to write a different fingerprint
71+
guard fingerprint == existing else {
72+
throw PackageFingerprintStorageError.conflict(given: fingerprint, existing: existing)
73+
}
74+
// Don't need to do anything if fingerprints are the same
75+
return
76+
}
77+
78+
var fingerprints = packageFingerprints.removeValue(forKey: version) ?? [:]
79+
fingerprints[fingerprint.origin.kind] = fingerprint
80+
packageFingerprints[version] = fingerprints
81+
82+
try self.saveToDisk(package: package, fingerprints: packageFingerprints)
83+
}
84+
callback(.success(()))
85+
} catch {
86+
callback(.failure(error))
87+
}
88+
}
89+
90+
private func loadFromDisk(package: PackageIdentity) throws -> PackageFingerprints {
91+
let path = self.directoryPath.appending(component: package.fingerprintFilename)
92+
93+
guard self.fileSystem.exists(path) else {
94+
return .init()
95+
}
96+
97+
let buffer = try fileSystem.readFileContents(path).contents
98+
guard buffer.count > 0 else {
99+
return .init()
100+
}
101+
102+
let container = try self.decoder.decode(StorageModel.Container.self, from: Data(buffer))
103+
return try container.packageFingerprints()
104+
}
105+
106+
private func saveToDisk(package: PackageIdentity, fingerprints: PackageFingerprints) throws {
107+
if !self.fileSystem.exists(self.directoryPath) {
108+
try self.fileSystem.createDirectory(self.directoryPath, recursive: true)
109+
}
110+
111+
let container = StorageModel.Container(fingerprints)
112+
let buffer = try encoder.encode(container)
113+
114+
let path = self.directoryPath.appending(component: package.fingerprintFilename)
115+
try self.fileSystem.writeFileContents(path, bytes: ByteString(buffer))
116+
}
117+
118+
private func withLock<T>(_ body: () throws -> T) throws -> T {
119+
if !self.fileSystem.exists(self.directoryPath) {
120+
try self.fileSystem.createDirectory(self.directoryPath, recursive: true)
121+
}
122+
return try self.fileSystem.withLock(on: self.directoryPath, type: .exclusive, body)
123+
}
124+
125+
private func makeAsync<T>(_ closure: @escaping (Result<T, Error>) -> Void, on queue: DispatchQueue) -> (Result<T, Error>) -> Void {
126+
{ result in queue.async { closure(result) } }
127+
}
128+
}
129+
130+
private enum StorageModel {
131+
struct Container: Codable {
132+
let versionFingerprints: [String: [String: StoredFingerprint]]
133+
134+
init(_ versionFingerprints: PackageFingerprints) {
135+
self.versionFingerprints = Dictionary(uniqueKeysWithValues: versionFingerprints.map { version, fingerprints in
136+
let fingerprintByKind: [String: StoredFingerprint] = Dictionary(uniqueKeysWithValues: fingerprints.map { kind, fingerprint in
137+
let origin: String
138+
switch fingerprint.origin {
139+
case .sourceControl(let url):
140+
origin = url.absoluteString
141+
case .registry(let url):
142+
origin = url.absoluteString
143+
}
144+
return (kind.rawValue, StoredFingerprint(origin: origin, fingerprint: fingerprint.value))
145+
})
146+
return (version.description, fingerprintByKind)
147+
})
148+
}
149+
150+
func packageFingerprints() throws -> PackageFingerprints {
151+
try Dictionary(uniqueKeysWithValues: self.versionFingerprints.map { version, fingerprints in
152+
let fingerprintByKind: [Fingerprint.Kind: Fingerprint] = try Dictionary(uniqueKeysWithValues: fingerprints.map { kind, fingerprint in
153+
guard let kind = Fingerprint.Kind(rawValue: kind) else {
154+
throw SerializationError.unknownKind(kind)
155+
}
156+
guard let originURL = Foundation.URL(string: fingerprint.origin) else {
157+
throw SerializationError.invalidURL(fingerprint.origin)
158+
}
159+
160+
let origin: Fingerprint.Origin
161+
switch kind {
162+
case .sourceControl:
163+
origin = .sourceControl(originURL)
164+
case .registry:
165+
origin = .registry(originURL)
166+
}
167+
168+
return (kind, Fingerprint(origin: origin, value: fingerprint.fingerprint))
169+
})
170+
return (Version(stringLiteral: version), fingerprintByKind)
171+
})
172+
}
173+
}
174+
175+
struct StoredFingerprint: Codable {
176+
let origin: String
177+
let fingerprint: String
178+
}
179+
}
180+
181+
extension PackageIdentity {
182+
var fingerprintFilename: String {
183+
"\(self.description).json"
184+
}
185+
}
186+
187+
private enum SerializationError: Error {
188+
case unknownKind(String)
189+
case invalidURL(String)
190+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2021 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See http://swift.org/LICENSE.txt for license information
8+
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import struct Foundation.URL
12+
import TSCUtility
13+
14+
public struct Fingerprint: Equatable {
15+
public let origin: Origin
16+
public let value: String
17+
}
18+
19+
public extension Fingerprint {
20+
enum Kind: String, Hashable {
21+
case sourceControl
22+
case registry
23+
}
24+
25+
enum Origin: Equatable, CustomStringConvertible {
26+
case sourceControl(Foundation.URL)
27+
case registry(Foundation.URL)
28+
29+
var kind: Fingerprint.Kind {
30+
switch self {
31+
case .sourceControl:
32+
return .sourceControl
33+
case .registry:
34+
return .registry
35+
}
36+
}
37+
38+
var url: Foundation.URL? {
39+
switch self {
40+
case .sourceControl(let url):
41+
return url
42+
case .registry(let url):
43+
return url
44+
}
45+
}
46+
47+
public var description: String {
48+
switch self {
49+
case .sourceControl(let url):
50+
return "sourceControl(\(url))"
51+
case .registry(let url):
52+
return "registry(\(url))"
53+
}
54+
}
55+
}
56+
}
57+
58+
public typealias PackageFingerprints = [Version: [Fingerprint.Kind: Fingerprint]]
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2021 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See http://swift.org/LICENSE.txt for license information
8+
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import Basics
12+
import Dispatch
13+
import PackageModel
14+
import TSCUtility
15+
16+
public protocol PackageFingerprintStorage {
17+
func get(package: PackageIdentity,
18+
version: Version,
19+
observabilityScope: ObservabilityScope,
20+
callbackQueue: DispatchQueue,
21+
callback: @escaping (Result<[Fingerprint.Kind: Fingerprint], Error>) -> Void)
22+
23+
func put(package: PackageIdentity,
24+
version: Version,
25+
fingerprint: Fingerprint,
26+
observabilityScope: ObservabilityScope,
27+
callbackQueue: DispatchQueue,
28+
callback: @escaping (Result<Void, Error>) -> Void)
29+
}
30+
31+
public enum PackageFingerprintStorageError: Error, Equatable {
32+
case conflict(given: Fingerprint, existing: Fingerprint)
33+
case notFound
34+
}

0 commit comments

Comments
 (0)