Skip to content

Commit 18233f2

Browse files
committed
1.5.1
1 parent bec931d commit 18233f2

5 files changed

Lines changed: 194 additions & 12 deletions

File tree

Sources/MediaLib/App/RemoteCredentialStore.swift

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Foundation
2+
import CryptoKit
23
import MediaLibCore
34

45
struct RemoteSourceCredential: Codable {
@@ -95,15 +96,23 @@ final class RemoteCredentialStore {
9596
throw NSError(domain: "MediaLib.RemoteCredentialStore", code: -1, userInfo: [NSLocalizedDescriptionKey: "无法定位凭据存储目录"])
9697
}
9798
try io.write(data, url)
99+
if let legacyURL = legacyFileURL(for: sourceID, directoryOverride: directoryOverride),
100+
legacyURL.standardizedFileURL != url.standardizedFileURL,
101+
FileManager.default.fileExists(atPath: legacyURL.path) {
102+
try? io.remove(legacyURL)
103+
}
98104
}
99105

100106
private static func load(sourceID: String, directoryOverride: URL?, io: IO) throws -> RemoteSourceCredential? {
101107
// 只读文件,绝不读 keychain。读取旧 keychain(SecItemCopyMatching 取数据)会因 ad-hoc 签名
102108
// 每次更新变化而弹出系统钥匙串密码框——这正是"更新后首次打开要输密码"的根因,故彻底不再读取。
103109
// 旧用户更新后需在设置里重新登录一次 Emby/NAS(一次性,且不会弹任何系统密码框)。
104110
if let url = io.fileURL(sourceID, directoryOverride),
105-
let data = try? io.read(url),
106-
let credential = try? JSONDecoder().decode(RemoteSourceCredential.self, from: data) {
111+
let credential = readCredential(at: url, io: io) {
112+
return credential
113+
}
114+
if let url = legacyFileURL(for: sourceID, directoryOverride: directoryOverride),
115+
let credential = readCredential(at: url, io: io) {
107116
return credential
108117
}
109118
return nil
@@ -113,6 +122,10 @@ final class RemoteCredentialStore {
113122
if let url = io.fileURL(sourceID, directoryOverride) {
114123
try? io.remove(url)
115124
}
125+
if let url = legacyFileURL(for: sourceID, directoryOverride: directoryOverride),
126+
FileManager.default.fileExists(atPath: url.path) {
127+
try? io.remove(url)
128+
}
116129
}
117130

118131
private static func directory(directoryOverride: URL?) -> URL? {
@@ -135,9 +148,37 @@ final class RemoteCredentialStore {
135148
}
136149

137150
private static func fileURL(for sourceID: String, directoryOverride: URL?) -> URL? {
151+
directory(directoryOverride: directoryOverride)?.appendingPathComponent(fileName(for: sourceID))
152+
}
153+
154+
private static func legacyFileURL(for sourceID: String, directoryOverride: URL?) -> URL? {
155+
directory(directoryOverride: directoryOverride)?.appendingPathComponent(legacyFileName(for: sourceID))
156+
}
157+
158+
private static func readCredential(at url: URL, io: IO) -> RemoteSourceCredential? {
159+
guard let data = try? io.read(url),
160+
let credential = try? JSONDecoder().decode(RemoteSourceCredential.self, from: data) else {
161+
return nil
162+
}
163+
return credential
164+
}
165+
166+
private static func fileName(for sourceID: String) -> String {
167+
let prefix = sanitizedSourceIDPrefix(sourceID)
168+
let digest = SHA256.hash(data: Data(sourceID.utf8))
169+
let suffix = digest.prefix(8).map { String(format: "%02x", $0) }.joined()
170+
return "\(prefix)-\(suffix).json"
171+
}
172+
173+
private static func legacyFileName(for sourceID: String) -> String {
174+
let safe = sourceID.unicodeScalars.map { CharacterSet.alphanumerics.contains($0) ? Character($0) : "_" }
175+
return "\(String(safe)).json"
176+
}
177+
178+
private static func sanitizedSourceIDPrefix(_ sourceID: String) -> String {
138179
let safe = sourceID.unicodeScalars.map { CharacterSet.alphanumerics.contains($0) ? Character($0) : "_" }
139-
let name = String(safe)
140-
return directory(directoryOverride: directoryOverride)?.appendingPathComponent("\(name).json")
180+
let prefix = String(String(safe).prefix(48)).trimmingCharacters(in: CharacterSet(charactersIn: "_"))
181+
return prefix.isEmpty ? "source" : prefix
141182
}
142183

143184
}

Sources/MediaLibCore/Services/AppSettingsStore.swift

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,28 +75,36 @@ public final class AppSettingsStore {
7575
return settings
7676
}
7777

78-
public func save(_ settings: AppSettings) {
78+
@discardableResult
79+
public func save(_ settings: AppSettings) -> Bool {
7980
// 1) secret → 独立 0600 文件
80-
secretStore.save(collectSecrets(from: settings))
81+
let secrets = collectSecrets(from: settings)
82+
let secretsSaved = secretStore.save(secrets)
83+
guard secretsSaved || secrets.isEmpty else { return false }
8184
// 2) 去除 secret 的副本 → UserDefaults(不再有明文密钥落入 prefs)
8285
var stripped = settings
8386
for field in Self.secretFields {
8487
stripped[keyPath: field.keyPath] = nil
8588
}
86-
guard let data = try? JSONEncoder().encode(stripped) else { return }
89+
guard let data = try? JSONEncoder().encode(stripped) else { return false }
8790
defaults.set(data, forKey: key)
91+
return secretsSaved
8892
}
8993

90-
public func saveAsync(_ settings: AppSettings) async {
94+
@discardableResult
95+
public func saveAsync(_ settings: AppSettings) async -> Bool {
9196
// 1) secret → 独立 0600 文件
92-
await secretStore.saveAsync(collectSecrets(from: settings))
97+
let secrets = collectSecrets(from: settings)
98+
let secretsSaved = await secretStore.saveAsync(secrets)
99+
guard secretsSaved || secrets.isEmpty else { return false }
93100
// 2) 去除 secret 的副本 → UserDefaults(不再有明文密钥落入 prefs)
94101
var stripped = settings
95102
for field in Self.secretFields {
96103
stripped[keyPath: field.keyPath] = nil
97104
}
98-
guard let data = try? JSONEncoder().encode(stripped) else { return }
105+
guard let data = try? JSONEncoder().encode(stripped) else { return false }
99106
defaults.set(data, forKey: key)
107+
return secretsSaved
100108
}
101109

102110
// MARK: - Helpers

Tests/MediaLibCoreTests/Services/AppSettingsSecretStorageTests.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,27 @@ final class AppSettingsSecretStorageTests: XCTestCase {
7272
XCTAssertEqual(storedSecrets["traktRefreshToken"], "trakt-refresh")
7373
}
7474

75+
func testAsyncSaveReturnsFalseAndDoesNotWriteStrippedBlobWhenSecretPersistenceFails() async throws {
76+
struct TestSecretWriteError: Error {}
77+
78+
let failingSecretStore = SecretStore(
79+
directory: tempDirectory,
80+
io: SecretStore.IO(
81+
read: { _ in Data("{}".utf8) },
82+
write: { _, _ in throw TestSecretWriteError() }
83+
)
84+
)
85+
let store = AppSettingsStore(defaults: defaults, secretStore: failingSecretStore)
86+
var settings = AppSettings()
87+
settings.traktAccessToken = "async-token-that-must-not-be-lost"
88+
settings.tmdbLanguage = "ko-KR"
89+
90+
let saved = await store.saveAsync(settings)
91+
92+
XCTAssertFalse(saved)
93+
XCTAssertNil(defaults.data(forKey: blobKey))
94+
}
95+
7596
func testAsyncLoadAppliesStoredSecretsWhenSettingsBlobIsMissing() async {
7697
let secretStore = SecretStore(directory: tempDirectory)
7798
await secretStore.saveAsync([

Tests/MediaLibCoreTests/Services/AppSettingsStoreAuditTests.swift

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,54 @@ final class AppSettingsStoreAuditTests: XCTestCase {
9292
let newBlob = try JSONDecoder().decode(AppSettings.self, from: newBlobData)
9393
XCTAssertNil(newBlob.tmdbAPIKey, "旧版遗留明文必须在首次 load 时被自动迁移剥离并清零!")
9494
}
95+
96+
func testSaveReturnsFalseAndDoesNotWriteStrippedBlobWhenSecretPersistenceFails() throws {
97+
struct TestSecretWriteError: Error {}
98+
99+
let failingSecretStore = SecretStore(
100+
directory: tempDir,
101+
io: SecretStore.IO(
102+
read: { _ in Data("{}".utf8) },
103+
write: { _, _ in throw TestSecretWriteError() }
104+
)
105+
)
106+
let store = AppSettingsStore(defaults: defaults, secretStore: failingSecretStore)
107+
var settings = AppSettings()
108+
settings.tmdbAPIKey = "tmdb-key-that-must-not-be-lost"
109+
settings.tmdbLanguage = "en-US"
110+
111+
let saved = store.save(settings)
112+
113+
XCTAssertFalse(saved)
114+
XCTAssertNil(
115+
defaults.data(forKey: "MediaLib.AppSettings"),
116+
"SecretStore 写失败且存在敏感字段时,不应继续写入已剥离 secret 的 blob,避免把唯一凭据来源抹掉。"
117+
)
118+
}
119+
120+
func testFailedLegacySecretMigrationKeepsPlaintextBlobForRetryInsteadOfScrubbingIt() throws {
121+
struct TestSecretWriteError: Error {}
122+
123+
var legacySettings = AppSettings()
124+
legacySettings.tmdbAPIKey = "legacy-key-needs-retry"
125+
legacySettings.tmdbLanguage = "ja-JP"
126+
let legacyData = try JSONEncoder().encode(legacySettings)
127+
defaults.set(legacyData, forKey: "MediaLib.AppSettings")
128+
let failingSecretStore = SecretStore(
129+
directory: tempDir,
130+
io: SecretStore.IO(
131+
read: { _ in Data("{}".utf8) },
132+
write: { _, _ in throw TestSecretWriteError() }
133+
)
134+
)
135+
let store = AppSettingsStore(defaults: defaults, secretStore: failingSecretStore)
136+
137+
let loaded = store.load()
138+
139+
XCTAssertEqual(loaded.tmdbAPIKey, "legacy-key-needs-retry")
140+
let blobData = try XCTUnwrap(defaults.data(forKey: "MediaLib.AppSettings"))
141+
let retriableBlob = try JSONDecoder().decode(AppSettings.self, from: blobData)
142+
XCTAssertEqual(retriableBlob.tmdbAPIKey, "legacy-key-needs-retry")
143+
XCTAssertEqual(retriableBlob.tmdbLanguage, "ja-JP")
144+
}
95145
}

Tests/MediaLibTests/App/RemoteCredentialStoreTests.swift

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ final class RemoteCredentialStoreTests: XCTestCase {
2828

2929
let files = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)
3030
XCTAssertEqual(files.count, 1)
31-
XCTAssertEqual(files[0].lastPathComponent, "server_one_with_unsafe_chars.json")
31+
XCTAssertTrue(files[0].lastPathComponent.hasPrefix("server_one_with_unsafe_chars-"))
32+
XCTAssertTrue(files[0].lastPathComponent.hasSuffix(".json"))
3233
let attributes = try FileManager.default.attributesOfItem(atPath: files[0].path)
3334
XCTAssertEqual((attributes[.posixPermissions] as? NSNumber)?.intValue, 0o600)
3435

@@ -79,6 +80,64 @@ final class RemoteCredentialStoreTests: XCTestCase {
7980
XCTAssertEqual(files.count, count)
8081
}
8182

83+
func testSourceIDsWithSameLegacySanitizedNameUseDistinctCredentialFiles() async throws {
84+
let directory = try makeTemporaryDirectory()
85+
let store = RemoteCredentialStore(directory: directory)
86+
let colonCredential = RemoteSourceCredential(
87+
kind: "emby",
88+
serverURL: "https://colon.example.test",
89+
username: nil,
90+
password: nil,
91+
accessToken: "colon-token",
92+
userID: nil
93+
)
94+
let slashCredential = RemoteSourceCredential(
95+
kind: "emby",
96+
serverURL: "https://slash.example.test",
97+
username: nil,
98+
password: nil,
99+
accessToken: "slash-token",
100+
userID: nil
101+
)
102+
103+
try await store.saveAsync(colonCredential, sourceID: "server:a")
104+
try await store.saveAsync(slashCredential, sourceID: "server/a")
105+
106+
let files = try FileManager.default.contentsOfDirectory(atPath: directory.path)
107+
XCTAssertEqual(files.count, 2)
108+
XCTAssertEqual(Set(files).count, 2)
109+
XCTAssertFalse(files.contains("server_a.json"))
110+
let loadedColonCredential = try await store.loadAsync(sourceID: "server:a")
111+
let loadedSlashCredential = try await store.loadAsync(sourceID: "server/a")
112+
XCTAssertEqual(loadedColonCredential?.accessToken, "colon-token")
113+
XCTAssertEqual(loadedSlashCredential?.accessToken, "slash-token")
114+
}
115+
116+
func testLoadAndDeleteRemainCompatibleWithLegacySanitizedFilename() async throws {
117+
let directory = try makeTemporaryDirectory()
118+
let store = RemoteCredentialStore(directory: directory)
119+
let sourceID = "legacy:source/path"
120+
let legacyURL = directory.appendingPathComponent("legacy_source_path.json")
121+
let legacyCredential = RemoteSourceCredential(
122+
kind: "plex",
123+
serverURL: "https://legacy.example.test",
124+
username: "legacy-user",
125+
password: "legacy-password",
126+
accessToken: "legacy-token",
127+
userID: "legacy-user-id"
128+
)
129+
try JSONEncoder().encode(legacyCredential).write(to: legacyURL, options: .atomic)
130+
131+
let loaded = try await store.loadAsync(sourceID: sourceID)
132+
133+
XCTAssertEqual(loaded?.serverURL, "https://legacy.example.test")
134+
XCTAssertEqual(loaded?.accessToken, "legacy-token")
135+
136+
await store.deleteAsync(sourceID: sourceID)
137+
138+
XCTAssertFalse(FileManager.default.fileExists(atPath: legacyURL.path))
139+
}
140+
82141
func testAsyncLoadReturnsNilForCorruptedJSONAndDeleteIsIdempotent() async throws {
83142
let directory = try makeTemporaryDirectory()
84143
let store = RemoteCredentialStore(directory: directory)
@@ -94,7 +153,10 @@ final class RemoteCredentialStoreTests: XCTestCase {
94153
),
95154
sourceID: sourceID
96155
)
97-
let fileURL = directory.appendingPathComponent("corrupted.json")
156+
let fileURL = try XCTUnwrap(FileManager.default.contentsOfDirectory(
157+
at: directory,
158+
includingPropertiesForKeys: nil
159+
).first)
98160
try Data("{\"serverURL\":".utf8).write(to: fileURL)
99161

100162
let corruptedCredential = try await store.loadAsync(sourceID: sourceID)

0 commit comments

Comments
 (0)