apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README | LICENSE

RadrootsIdentityCustodyTests.swift (21436B)


      1 import Foundation
      2 @testable import RadrootsKit
      3 import RadrootsKitTesting
      4 import Testing
      5 
      6 private let identityTestNow: UInt64 = 1_800_000_000_000
      7 private let aliceSecretHex = "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"
      8 private let alicePublicKeyHex = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"
      9 private let aliceNsec = "nsec1zrznqntvnt36rgt00ps0rny0tca8vgj6ye3m82vf5rthtyvm0h6syu7drz"
     10 private let bobSecretHex = "59392e9068f66431b12f70218fb61281cb6b433d7f27c5abee1f1a3fe1a96ff8"
     11 
     12 @Test func identitySecretMaterialParsesHexAndNsecWithoutDebugDisclosure() throws {
     13     let hex = try RadrootsIdentitySecretMaterial(importText: aliceSecretHex.uppercased())
     14     let nsec = try RadrootsIdentitySecretMaterial(importText: aliceNsec)
     15 
     16     #expect(hex.copyBytes() == nsec.copyBytes())
     17     #expect(hex.copyBytes().count == 32)
     18     #expect(!String(reflecting: hex).contains(aliceSecretHex))
     19     #expect(throws: RadrootsIdentityCustodyError.invalidSecret) {
     20         _ = try RadrootsIdentitySecretMaterial(importText: "nsec1invalid")
     21     }
     22 }
     23 
     24 @Test func identityLifecycleIsOneActiveOpaqueAndSignatureBound() async throws {
     25     let fixture = try makeIdentityFixture()
     26     #expect(await fixture.custody.snapshot().state == .absent)
     27 
     28     let imported = try await fixture.custody.importIdentity(
     29         RadrootsIdentitySecretMaterial(importText: aliceSecretHex),
     30         label: " Alice "
     31     )
     32     #expect(imported.state == .unlocked)
     33     #expect(imported.identity?.publicKeyHex == alicePublicKeyHex)
     34     #expect(imported.identity?.label == "Alice")
     35     let firstHandle = try #require(imported.signerHandle)
     36 
     37     await #expect(throws: RadrootsIdentityCustodyError.identityAlreadyExists) {
     38         try await fixture.custody.importIdentity(
     39             RadrootsIdentitySecretMaterial(importText: bobSecretHex)
     40         )
     41     }
     42 
     43     let digest = Data(repeating: 0x42, count: 32)
     44     let request = try RadrootsOpaqueSignRequest(
     45         operationID: UUID().uuidString.lowercased(),
     46         signerHandle: firstHandle,
     47         publicKeyHex: alicePublicKeyHex,
     48         digest: digest,
     49         purpose: .nostrEvent,
     50         deadlineUnixMilliseconds: identityTestNow + 1000
     51     )
     52     let signature = try await fixture.custody.sign(request)
     53     #expect(signature.operationID == request.operationID)
     54     #expect(signature.signature.count == 64)
     55     #expect(
     56         RadrootsIdentityCryptography().verify(
     57             signature: signature.signature,
     58             digest: digest,
     59             publicKeyHex: alicePublicKeyHex
     60         )
     61     )
     62     #expect(!String(reflecting: request).contains(digest.base64EncodedString()))
     63     #expect(!String(reflecting: signature).contains(signature.signature.base64EncodedString()))
     64 
     65     await fixture.custody.lockIdentity()
     66     #expect(await fixture.custody.snapshot().state == .locked)
     67     let unlocked = try await fixture.custody.selectIdentity(
     68         identityHandle: #require(imported.identity?.identityHandle)
     69     )
     70     #expect(unlocked.state == .unlocked)
     71     #expect(unlocked.signerHandle != firstHandle)
     72     await #expect(throws: RadrootsIdentityCustodyError.identityNotFound) {
     73         try await fixture.custody.selectIdentity(
     74             identityHandle: "rrid1_\(String(repeating: "0", count: 64))"
     75         )
     76     }
     77     await #expect(throws: RadrootsIdentityCustodyError.staleSigner) {
     78         try await fixture.custody.sign(request)
     79     }
     80 }
     81 
     82 @Test func identitySigningRejectsTimeoutCancellationAndWrongBinding() async throws {
     83     let fixture = try makeIdentityFixture()
     84     let imported = try await fixture.custody.importIdentity(
     85         RadrootsIdentitySecretMaterial(importText: aliceSecretHex)
     86     )
     87     let handle = try #require(imported.signerHandle)
     88     let operationID = UUID().uuidString.lowercased()
     89     let expired = try RadrootsOpaqueSignRequest(
     90         operationID: operationID,
     91         signerHandle: handle,
     92         publicKeyHex: alicePublicKeyHex,
     93         digest: Data(repeating: 1, count: 32),
     94         purpose: .blossomUpload,
     95         deadlineUnixMilliseconds: identityTestNow - 1
     96     )
     97     await #expect(throws: RadrootsIdentityCustodyError.timedOut) {
     98         try await fixture.custody.sign(expired)
     99     }
    100 
    101     try await fixture.custody.cancelSigning(operationID: operationID)
    102     let cancelled = try RadrootsOpaqueSignRequest(
    103         operationID: operationID,
    104         signerHandle: handle,
    105         publicKeyHex: alicePublicKeyHex,
    106         digest: Data(repeating: 1, count: 32),
    107         purpose: .blossomUpload,
    108         deadlineUnixMilliseconds: identityTestNow + 1
    109     )
    110     await #expect(throws: RadrootsIdentityCustodyError.cancelled) {
    111         try await fixture.custody.sign(cancelled)
    112     }
    113 
    114     let wrongKey = String(repeating: "0", count: 64)
    115     let wrongBinding = try RadrootsOpaqueSignRequest(
    116         operationID: UUID().uuidString.lowercased(),
    117         signerHandle: handle,
    118         publicKeyHex: wrongKey,
    119         digest: Data(repeating: 2, count: 32),
    120         purpose: .nostrEvent,
    121         deadlineUnixMilliseconds: identityTestNow + 1
    122     )
    123     await #expect(throws: RadrootsIdentityCustodyError.staleSigner) {
    124         try await fixture.custody.sign(wrongBinding)
    125     }
    126 }
    127 
    128 @Test func identityReplacementAndDeleteRecoverAfterMetadataFailures() async throws {
    129     let fixture = try makeIdentityFixture()
    130     fixture.metadata.failNext(.write, slot: .activeIdentity)
    131     await #expect(throws: RadrootsIdentityCustodyError.recoveryRequired) {
    132         try await fixture.custody.importIdentity(
    133             RadrootsIdentitySecretMaterial(importText: aliceSecretHex)
    134         )
    135     }
    136     #expect(await fixture.custody.snapshot().state == .recoveryRequired)
    137     let recovered = try await fixture.custody.recover()
    138     #expect(recovered.state == .locked)
    139     #expect(recovered.identity?.publicKeyHex == alicePublicKeyHex)
    140 
    141     fixture.metadata.failNext(.delete, slot: .activeIdentity)
    142     await #expect(throws: RadrootsIdentityCustodyError.recoveryRequired) {
    143         try await fixture.custody.deleteIdentity()
    144     }
    145     #expect(await fixture.custody.snapshot().state == .recoveryRequired)
    146     #expect(try await fixture.custody.recover().state == .absent)
    147     #expect(fixture.secureStore.keys().isEmpty)
    148 }
    149 
    150 @Test func corruptMetadataIsDistinctFromAbsenceAndCanBeRepaired() async throws {
    151     let fixture = try makeIdentityFixture()
    152     _ = try await fixture.custody.importIdentity(
    153         RadrootsIdentitySecretMaterial(importText: aliceSecretHex),
    154         label: "Before"
    155     )
    156     await fixture.custody.lockIdentity()
    157     fixture.metadata.replaceRawData(Data("not-json".utf8), for: .activeIdentity)
    158 
    159     let corrupt = await fixture.custody.snapshot()
    160     #expect(corrupt.state == .corrupt)
    161     #expect(corrupt.recoveryCode == "identity.corrupt_metadata")
    162     #expect(fixture.metadata.rawData(for: .quarantinedMetadata) == Data("not-json".utf8))
    163 
    164     let repaired = try await fixture.custody.repairCorruptMetadata(label: "Recovered")
    165     #expect(repaired.state == .unlocked)
    166     #expect(repaired.identity?.publicKeyHex == alicePublicKeyHex)
    167     #expect(repaired.identity?.label == "Recovered")
    168     #expect(fixture.metadata.rawData(for: .quarantinedMetadata) == nil)
    169 }
    170 
    171 @Test func corruptTransactionJournalRequiresRecoveryWithoutClaimingAbsence() async throws {
    172     let fixture = try makeIdentityFixture()
    173     _ = try await fixture.custody.importIdentity(
    174         RadrootsIdentitySecretMaterial(importText: aliceSecretHex)
    175     )
    176     await fixture.custody.lockIdentity()
    177     fixture.metadata.replaceRawData(Data("not-a-journal".utf8), for: .transactionJournal)
    178 
    179     let snapshot = await fixture.custody.snapshot()
    180     #expect(snapshot.state == .corrupt)
    181     #expect(snapshot.identity == nil)
    182     #expect(snapshot.recoveryCode == "identity.corrupt_metadata")
    183     await #expect(throws: RadrootsIdentityCustodyError.corruptMetadata) {
    184         try await fixture.custody.recover()
    185     }
    186 }
    187 
    188 @Test func identityCustodyRoundTripsAgainstAppleStorageAdapters() async throws {
    189     let suffix = UUID().uuidString.lowercased()
    190     let namespace = "native-storage-\(suffix)"
    191     let servicePrefix = "org.radroots.tests.identity.\(suffix)"
    192     let suiteName = "org.radroots.tests.identity.metadata.\(suffix)"
    193     let keychain = RadrootsAppleKeychainSecureStore(servicePrefix: servicePrefix)
    194     let defaults = try #require(UserDefaults(suiteName: suiteName))
    195     defer {
    196         try? keychain.deleteNamespace(namespace)
    197         defaults.removePersistentDomain(forName: suiteName)
    198     }
    199     let custody = try RadrootsIdentityCustody(
    200         configuration: RadrootsIdentityCustodyConfiguration(
    201             namespace: namespace,
    202             secretPolicy: .secureLocalSecret
    203         ),
    204         secureStore: keychain,
    205         metadataStore: RadrootsAppleIdentityMetadataStore(
    206             namespace: namespace,
    207             userDefaults: defaults
    208         ),
    209         userPresence: RadrootsFakeUserPresence(),
    210         now: { identityTestNow }
    211     )
    212 
    213     let imported = try await custody.importIdentity(
    214         RadrootsIdentitySecretMaterial(importText: aliceSecretHex),
    215         label: "Native"
    216     )
    217     #expect(imported.state == .unlocked)
    218     #expect(imported.identity?.publicKeyHex == alicePublicKeyHex)
    219 
    220     await custody.lockIdentity()
    221     #expect(await custody.snapshot().state == .locked)
    222     #expect(try await custody.unlockIdentity().state == .unlocked)
    223     #expect(try await custody.deleteIdentity().state == .absent)
    224     #expect(
    225         try keychain.contains(
    226             RadrootsSecureStoreKey(namespace: namespace, name: "active_secret_v1")
    227         ) == false
    228     )
    229 }
    230 
    231 @Test func protectedDataAndUserPresenceFailuresDoNotClaimIdentitySuccess() async throws {
    232     let secureStore = RadrootsInMemorySecureStore()
    233     let metadata = RadrootsInMemoryIdentityMetadataStore()
    234     let presence = RadrootsFakeUserPresence(verificationOutcome: .success(false))
    235     let custody = try RadrootsIdentityCustody(
    236         configuration: RadrootsIdentityCustodyConfiguration(namespace: "protected-test"),
    237         secureStore: secureStore,
    238         metadataStore: metadata,
    239         userPresence: presence,
    240         protectedData: RadrootsProtectedDataProvider { .available },
    241         now: { identityTestNow }
    242     )
    243     await #expect(throws: RadrootsIdentityCustodyError.userPresenceRequired) {
    244         try await custody.importIdentity(RadrootsIdentitySecretMaterial(importText: aliceSecretHex))
    245     }
    246     #expect(await custody.snapshot().state == .absent)
    247 
    248     let unavailable = try RadrootsIdentityCustody(
    249         configuration: RadrootsIdentityCustodyConfiguration(namespace: "locked-test"),
    250         secureStore: RadrootsInMemorySecureStore(),
    251         metadataStore: RadrootsInMemoryIdentityMetadataStore(),
    252         userPresence: RadrootsFakeUserPresence(),
    253         protectedData: RadrootsProtectedDataProvider { .locked },
    254         now: { identityTestNow }
    255     )
    256     await #expect(throws: RadrootsIdentityCustodyError.protectedDataUnavailable) {
    257         try await unavailable.createIdentity()
    258     }
    259     #expect(await unavailable.snapshot().state == .absent)
    260 }
    261 
    262 @Test func encryptedPortabilityRoundTripsAcrossIndependentHostsAndRejectsTampering() async throws {
    263     let source = try makeIdentityFixture(namespace: "portability-source")
    264     _ = try await source.custody.importIdentity(
    265         RadrootsIdentitySecretMaterial(importText: aliceNsec),
    266         label: "Portable"
    267     )
    268     let passphrase = try RadrootsIdentityPassphrase("correct horse battery staple")
    269     let envelope = try await source.custody.exportPortableIdentity(passphrase: passphrase)
    270     let serialized = envelope.serializedRepresentation
    271     let rendered = String(decoding: serialized, as: UTF8.self)
    272     #expect(envelope.version == 1)
    273     #expect(!rendered.contains(aliceSecretHex))
    274     #expect(!rendered.contains(aliceNsec))
    275     #expect(!String(reflecting: passphrase).contains("correct horse"))
    276 
    277     let destination = try makeIdentityFixture(namespace: "portability-destination")
    278     let imported = try await destination.custody.importPortableIdentity(
    279         envelope,
    280         passphrase: passphrase
    281     )
    282     #expect(imported.identity?.publicKeyHex == alicePublicKeyHex)
    283     #expect(imported.identity?.label == "Portable")
    284 
    285     let wrongDestination = try makeIdentityFixture(namespace: "portability-wrong")
    286     await #expect(throws: RadrootsIdentityCustodyError.portabilityAuthenticationFailed) {
    287         try await wrongDestination.custody.importPortableIdentity(
    288             envelope,
    289             passphrase: RadrootsIdentityPassphrase("incorrect but sufficiently long")
    290         )
    291     }
    292 
    293     let unsupported = serialized.replacingOccurrences(
    294         of: Data("\"version\":1".utf8),
    295         with: Data("\"version\":2".utf8)
    296     )
    297     #expect(throws: RadrootsIdentityCustodyError.unsupportedPortabilityEnvelope) {
    298         _ = try RadrootsIdentityPortabilityEnvelope(serializedRepresentation: unsupported)
    299     }
    300 }
    301 
    302 @Test func legacyIdentityMigrationIsIdempotentAndDeletesOnlyAfterCommit() async throws {
    303     let fixture = try makeIdentityFixture(namespace: "legacy-test")
    304     let legacyKey = RadrootsSecureStoreKey(namespace: "legacy", name: "selected_secret_hex")
    305     try fixture.secureStore.put(Data(aliceSecretHex.utf8), for: legacyKey)
    306 
    307     let migrated = try await fixture.custody.migrateLegacyIdentity(from: legacyKey, label: "Migrated")
    308     #expect(migrated.identity?.publicKeyHex == alicePublicKeyHex)
    309     #expect(try fixture.secureStore.get(legacyKey) == nil)
    310 
    311     try fixture.secureStore.put(Data(aliceSecretHex.utf8), for: legacyKey)
    312     let replayed = try await fixture.custody.migrateLegacyIdentity(from: legacyKey)
    313     #expect(replayed.identity?.publicKeyHex == alicePublicKeyHex)
    314     #expect(try fixture.secureStore.get(legacyKey) == nil)
    315 }
    316 
    317 @Test(arguments: [Data(repeating: 0, count: 32), Data([1]), Data(repeating: 1, count: 32)])
    318 func legacyMigrationRetainsGoodLegacyWhenActiveSecretIsInvalid(active: Data) async throws {
    319     let namespace = UUID().uuidString.lowercased()
    320     let fixture = try makeIdentityFixture(namespace: namespace)
    321     let original = try await fixture.custody.importIdentity(
    322         RadrootsIdentitySecretMaterial(importText: aliceSecretHex)
    323     )
    324     let legacyKey = RadrootsSecureStoreKey(namespace: "legacy", name: "selected_secret_hex")
    325     let legacy = Data(aliceSecretHex.utf8)
    326     try fixture.secureStore.put(legacy, for: legacyKey)
    327     try fixture.secureStore.put(
    328         active, for: RadrootsSecureStoreKey(namespace: namespace, name: "active_secret_v1")
    329     )
    330     await #expect(throws: (any Error).self) {
    331         try await fixture.custody.migrateLegacyIdentity(from: legacyKey)
    332     }
    333     #expect(try fixture.secureStore.get(legacyKey) == legacy)
    334     #expect(await fixture.custody.snapshot().identity == original.identity)
    335     #expect(try fixture.secureStore.get(
    336         RadrootsSecureStoreKey(namespace: namespace, name: "active_secret_v1")
    337     ) == active)
    338 }
    339 
    340 @Test func legacyMigrationReadDenialPreservesLegacyAndInstalledIdentity() async throws {
    341     let namespace = UUID().uuidString.lowercased()
    342     let store = UnreadableActiveIdentityStore()
    343     let custody = try RadrootsIdentityCustody(
    344         configuration: RadrootsIdentityCustodyConfiguration(namespace: namespace),
    345         secureStore: store, metadataStore: RadrootsInMemoryIdentityMetadataStore(),
    346         userPresence: RadrootsFakeUserPresence(), now: { identityTestNow }
    347     )
    348     let original = try await custody.importIdentity(
    349         RadrootsIdentitySecretMaterial(importText: aliceSecretHex)
    350     )
    351     let key = RadrootsSecureStoreKey(namespace: "legacy", name: "selected_secret_hex")
    352     try store.put(Data(aliceSecretHex.utf8), for: key)
    353     await #expect(throws: RadrootsIdentityCustodyError.storageUnavailable) {
    354         try await custody.migrateLegacyIdentity(from: key)
    355     }
    356     #expect(try store.get(key) == Data(aliceSecretHex.utf8))
    357     #expect(await custody.snapshot().identity == original.identity)
    358 }
    359 
    360 private final class UnreadableActiveIdentityStore: RadrootsSecureStore, Sendable {
    361     private let backing = RadrootsInMemorySecureStore()
    362 
    363     func put(_ value: Data, for key: RadrootsSecureStoreKey, policy: RadrootsSecretAccessPolicy) throws {
    364         try backing.put(value, for: key, policy: policy)
    365     }
    366 
    367     func contains(_ key: RadrootsSecureStoreKey) throws -> Bool { try backing.contains(key) }
    368     func delete(_ key: RadrootsSecureStoreKey) throws { try backing.delete(key) }
    369     func deleteNamespace(_ namespace: String) throws { try backing.deleteNamespace(namespace) }
    370 
    371     func get(_ key: RadrootsSecureStoreKey) throws -> Data? {
    372         guard key.name != "active_secret_v1" else {
    373             throw RadrootsAppleSecurityError.permissionDenied
    374         }
    375         return try backing.get(key)
    376     }
    377 }
    378 
    379 @Test func opaqueSignerBridgeCancelsPendingWorkAndCompletesExactlyOnce() async throws {
    380     let secureStore = RadrootsInMemorySecureStore()
    381     let metadata = RadrootsInMemoryIdentityMetadataStore()
    382     let presence = ControllableIdentityPresence()
    383     let custody = try RadrootsIdentityCustody(
    384         configuration: RadrootsIdentityCustodyConfiguration(namespace: "bridge-test"),
    385         secureStore: secureStore,
    386         metadataStore: metadata,
    387         userPresence: presence,
    388         now: { identityTestNow }
    389     )
    390     let imported = try await custody.importIdentity(
    391         RadrootsIdentitySecretMaterial(importText: aliceSecretHex)
    392     )
    393     let signerHandle = try #require(imported.signerHandle)
    394     await presence.suspendNextVerification()
    395     let request = try RadrootsOpaqueSignRequest(
    396         operationID: UUID().uuidString.lowercased(),
    397         signerHandle: signerHandle,
    398         publicKeyHex: alicePublicKeyHex,
    399         digest: Data(repeating: 9, count: 32),
    400         purpose: .nostrEvent,
    401         deadlineUnixMilliseconds: identityTestNow + 1
    402     )
    403     let result = LockedIdentityResult()
    404     let cancellation = RadrootsOpaqueSignerBridge(custody: custody).submit(request) {
    405         result.record($0)
    406     }
    407     while await presence.pendingVerificationCount == 0 {
    408         await Task.yield()
    409     }
    410     cancellation.cancel()
    411     await presence.resumePending(verified: true)
    412     while result.count == 0 {
    413         await Task.yield()
    414     }
    415     #expect(result.count == 1)
    416     guard case .failure(.cancelled) = result.value else {
    417         Issue.record("expected one cancelled completion")
    418         return
    419     }
    420 }
    421 
    422 private struct IdentityFixture {
    423     let custody: RadrootsIdentityCustody
    424     let secureStore: RadrootsInMemorySecureStore
    425     let metadata: RadrootsInMemoryIdentityMetadataStore
    426 }
    427 
    428 private func makeIdentityFixture(namespace: String = UUID().uuidString.lowercased()) throws
    429     -> IdentityFixture
    430 {
    431     let secureStore = RadrootsInMemorySecureStore()
    432     let metadata = RadrootsInMemoryIdentityMetadataStore()
    433     let custody = try RadrootsIdentityCustody(
    434         configuration: RadrootsIdentityCustodyConfiguration(namespace: namespace),
    435         secureStore: secureStore,
    436         metadataStore: metadata,
    437         userPresence: RadrootsFakeUserPresence(),
    438         now: { identityTestNow }
    439     )
    440     return IdentityFixture(custody: custody, secureStore: secureStore, metadata: metadata)
    441 }
    442 
    443 private actor ControllableIdentityPresence: RadrootsUserPresence {
    444     private var suspendNext = false
    445     private var pending: [CheckedContinuation<RadrootsUserPresenceResult, Never>] = []
    446 
    447     func currentStatus() async throws -> RadrootsUserPresenceStatus {
    448         RadrootsUserPresenceStatus(
    449             support: .biometricsOrDeviceCredential,
    450             biometryKind: .faceID,
    451             canEvaluateDeviceCredential: true,
    452             canEvaluateBiometrics: true
    453         )
    454     }
    455 
    456     func verify(_ request: RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult {
    457         guard suspendNext else {
    458             return RadrootsUserPresenceResult(policy: request.policy, verified: true)
    459         }
    460         suspendNext = false
    461         return await withCheckedContinuation { continuation in
    462             pending.append(continuation)
    463         }
    464     }
    465 
    466     func suspendNextVerification() {
    467         suspendNext = true
    468     }
    469 
    470     var pendingVerificationCount: Int {
    471         pending.count
    472     }
    473 
    474     func resumePending(verified: Bool) {
    475         let continuations = pending
    476         pending.removeAll()
    477         for continuation in continuations {
    478             continuation.resume(
    479                 returning: RadrootsUserPresenceResult(
    480                     policy: .deviceOwnerAuthentication,
    481                     verified: verified
    482                 )
    483             )
    484         }
    485     }
    486 }
    487 
    488 private final class LockedIdentityResult: @unchecked Sendable {
    489     private let lock = NSLock()
    490     private var results: [Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>] = []
    491 
    492     var count: Int {
    493         lock.lock()
    494         defer { lock.unlock() }
    495         return results.count
    496     }
    497 
    498     var value: Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>? {
    499         lock.lock()
    500         defer { lock.unlock() }
    501         return results.first
    502     }
    503 
    504     func record(_ value: Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) {
    505         lock.lock()
    506         results.append(value)
    507         lock.unlock()
    508     }
    509 }
    510 
    511 private extension Data {
    512     func replacingOccurrences(of needle: Data, with replacement: Data) -> Data {
    513         guard let range = range(of: needle) else {
    514             return self
    515         }
    516         var copy = self
    517         copy.replaceSubrange(range, with: replacement)
    518         return copy
    519     }
    520 }