diff --git a/Copy/App/AppCoordinator.swift b/Copy/App/AppCoordinator.swift index 29109d2..1f5728a 100644 --- a/Copy/App/AppCoordinator.swift +++ b/Copy/App/AppCoordinator.swift @@ -14,6 +14,7 @@ final class AppCoordinator { private let persistQueue = DispatchQueue(label: "com.tarikbc.copy.persist", qos: .utility) private let saveErrors = SaveErrorReporter() private var retentionTimer: DispatchSourceTimer? + private var secretPruneTimer: DispatchSourceTimer? private(set) var isPaused = false /// True in a DEBUG build when the demo-mode flag is set (toggled from the status menu): /// runs against an isolated, reseeded demo database with curated mock data, and never @@ -446,6 +447,10 @@ final class AppCoordinator { self?.shelfController.setHideDuringScreenSharing(hide) self?.pasteStackController.setHideDuringScreenSharing(hide) } + pasteStackModel.hideSecrets = settings.hideSecrets + settings.onHideSecretsChange = { [pasteStackModel] hide in + pasteStackModel.hideSecrets = hide + } settings.onFloatingShelfChange = { [weak self] floating in self?.shelfController.setFloatingShelf(floating) } @@ -486,8 +491,11 @@ final class AppCoordinator { } func start() { + // Secret deletion also runs in demo mode; its database is rebuilt every launch. + settings.onSecretLifetimeChange = { [weak self] _ in self?.scheduleSecretPrune() } + scheduleSecretPrune() // Demo mode never captures the real clipboard (keeps the curated set pristine) and - // never prunes (its timestamps are backdated for the Time facets). + // never prunes by age (its timestamps are backdated for the Time facets). guard !isDemoMode else { return } monitor.start() runRetentionPrune() @@ -554,6 +562,37 @@ final class AppCoordinator { } } + /// Deletes secret items older than `secretLifetime`; favorites and pinboard items are kept. + private func runSecretPrune() { + guard let lifetime = settings.secretLifetime.interval else { return } + let cutoff = Date().addingTimeInterval(-lifetime) + let store = self.store + persistQueue.async { + do { + let deleted = try store.pruneSecrets(olderThan: cutoff) + if deleted > 0 { + NSLog("Copy: secret deletion removed \(deleted) item(s)") + } + } catch { + NSLog("Copy: secret deletion failed: \(error)") + } + } + } + + /// Checks now, then every five minutes at most. No timer when the lifetime is `.never`. + private func scheduleSecretPrune() { + secretPruneTimer?.cancel() + secretPruneTimer = nil + guard let lifetime = settings.secretLifetime.interval else { return } + runSecretPrune() + let interval = min(5 * 60, max(15, lifetime / 4)) + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(5)) + timer.setEventHandler { [weak self] in self?.runSecretPrune() } + timer.resume() + secretPruneTimer = timer + } + private func scheduleRetentionTimer() { let timer = DispatchSource.makeTimerSource(queue: .main) timer.schedule(deadline: .now() + Self.retentionInterval, repeating: Self.retentionInterval) diff --git a/Copy/App/AppDelegate.swift b/Copy/App/AppDelegate.swift index 4431645..cdf36df 100644 --- a/Copy/App/AppDelegate.swift +++ b/Copy/App/AppDelegate.swift @@ -178,7 +178,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { menu.addItem(empty) } for item in items { - let menuItem = NSMenuItem(title: item.displayTitle, + let menuItem = NSMenuItem(title: item.displayTitle(hidingSecrets: coordinator.settings.hideSecrets), action: #selector(pasteMenuItem(_:)), keyEquivalent: "") menuItem.target = self diff --git a/Copy/App/DemoData.swift b/Copy/App/DemoData.swift index ca687fa..806f8a2 100644 --- a/Copy/App/DemoData.swift +++ b/Copy/App/DemoData.swift @@ -123,6 +123,21 @@ enum DemoData { from: notes, at: ago(28 * minute)) try store.setTitle(itemID: checklistID, "Launch checklist") + // Fake secrets to show Hide Secrets, assembled from pieces so secret scanners ignore them. + let fakeOpenAIKey = "sk" + "-proj-" + "FAKEfakeFAKEfake1234567890abcd" + let fakeStripeKey = "sk" + "_test_" + "FAKE0000FAKE0000FAKE0000" + let fakeGitHubToken = "gh" + "p_" + "abcdefghijklmnopqrstuvwxyz0123456789" + let envID = try text( + "APP_ENV=staging\nOPENAI_API_KEY=\(fakeOpenAIKey)\nSTRIPE_SECRET_KEY=\(fakeStripeKey)\nDB_PASSWORD=Tr0ub4dor-and-3-more\nLOG_LEVEL=debug", + from: vscode, at: ago(24 * minute)) + try store.setTitle(itemID: envID, ".env (staging)") + try text(fakeGitHubToken, from: terminal, at: ago(25 * minute)) + try text("Card on file: 4242 4242 4242 4242, exp 04/29. Use it for the domain renewal.", + from: mail, at: ago(26 * minute)) + try link("https://api.example.com/v1/export?format=csv&access_token=a1b2c3d4e5f6g7h8i9j0", + title: "Export — Example API", + from: safari, at: ago(27 * minute), favicon: .systemTeal) + let figmaLinkID = try link("https://figma.com/file/9aB2/Copy-Marketing", title: "Copy — Marketing site (Figma)", from: figma, at: ago(34 * minute), favicon: .systemPurple) diff --git a/Copy/PasteStack/PasteStackModel.swift b/Copy/PasteStack/PasteStackModel.swift index 72af89e..d7fc47a 100644 --- a/Copy/PasteStack/PasteStackModel.swift +++ b/Copy/PasteStack/PasteStackModel.swift @@ -25,6 +25,9 @@ final class PasteStackModel { let store: ItemStore + /// Mirrors `SettingsStore.hideSecrets`: row titles mask secrets. + var hideSecrets = true + /// Bumped after an in-place edit so `PasteStackView` re-renders (the queue's uuids /// don't change on an edit, so nothing else would trigger a refresh). var revision = 0 diff --git a/Copy/PasteStack/PasteStackView.swift b/Copy/PasteStack/PasteStackView.swift index 208b0e8..70e4edb 100644 --- a/Copy/PasteStack/PasteStackView.swift +++ b/Copy/PasteStack/PasteStackView.swift @@ -115,6 +115,7 @@ struct PasteStackView: View { isNext: order[item.uuid] == 1, isDragging: draggingUUID == item.uuid, isEditable: isEditable(item), + hideSecrets: model.hideSecrets, onEdit: { onEdit(item) }, onRemove: { model.queue.remove(item.uuid) } ) @@ -216,6 +217,7 @@ private struct PasteStackRow: View { let isNext: Bool var isDragging: Bool = false var isEditable: Bool = true + var hideSecrets: Bool = true let onEdit: () -> Void let onRemove: () -> Void @@ -236,7 +238,7 @@ private struct PasteStackRow: View { .foregroundStyle(.secondary) .frame(width: 16) - Text(item.displayTitle) + Text(item.displayTitle(hidingSecrets: hideSecrets)) .font(Tokens.bodyMono) .lineLimit(1) diff --git a/Copy/Settings/PrivacySettings.swift b/Copy/Settings/PrivacySettings.swift index 068ec89..da12ec7 100644 --- a/Copy/Settings/PrivacySettings.swift +++ b/Copy/Settings/PrivacySettings.swift @@ -7,6 +7,7 @@ import UniformTypeIdentifiers struct PrivacySettings: View { @Bindable var settings: SettingsStore @State private var selectedBundleID: String? + @State private var showingSecretLifetimeInfo = false var body: some View { Form { @@ -20,6 +21,51 @@ struct PrivacySettings: View { .foregroundStyle(.secondary) } + Section { + Toggle("Hide Secrets", isOn: $settings.hideSecrets) + } header: { + Text("Secrets") + } footer: { + Text("Masks API keys, tokens, passwords and card numbers on cards and in previews. Pasting still uses the real value.") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Section { + LabeledContent { + Picker("Delete Secrets After", selection: $settings.secretLifetime) { + ForEach(SecretLifetime.allCases) { lifetime in + Text(lifetime.title).tag(lifetime) + } + } + .labelsHidden() + .fixedSize() + } label: { + HStack(spacing: 6) { + Text("Delete Secrets After") + Button { + showingSecretLifetimeInfo.toggle() + } label: { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel("How to keep a secret") + .popover(isPresented: $showingSecretLifetimeInfo, arrowEdge: .bottom) { + Text("To keep a card that has a secret, mark it as a favorite or file it on a pinboard. Favorites and pinboard items are never deleted automatically.") + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + .frame(width: 260, alignment: .leading) + .padding(12) + } + } + } + } footer: { + Text(secretLifetimeFooter) + .font(.footnote) + .foregroundStyle(.secondary) + } + Section { if settings.excludedBundleIDs.isEmpty { Text("No excluded apps") @@ -62,6 +108,13 @@ struct PrivacySettings: View { .formStyle(.grouped) } + private var secretLifetimeFooter: String { + if settings.secretLifetime == .never { + return "Cards with a secret are kept like any other card." + } + return "A card with an API key, token, password or card number is deleted \(settings.secretLifetime.title.lowercased()) after you copy it. Favorites and pinboard items are always kept." + } + private func excludedAppRow(_ bundleID: String) -> some View { HStack(spacing: 8) { if let icon = AppIconCache.icon(forBundleID: bundleID) { diff --git a/Copy/Settings/SettingsStore.swift b/Copy/Settings/SettingsStore.swift index 31ad53c..f979bd1 100644 --- a/Copy/Settings/SettingsStore.swift +++ b/Copy/Settings/SettingsStore.swift @@ -89,6 +89,52 @@ enum RetentionPeriod: String, CaseIterable { static let sliderOrder: [RetentionPeriod] = [.day, .week, .month, .threeMonths, .unlimited] } +/// How long an item containing a secret is kept after it is copied. Favorites and +/// pinboard items are never deleted. +enum SecretLifetime: String, CaseIterable, Identifiable { + case never + #if DEBUG + /// Debug only, for testing. + case minute + #endif + case hour + case fiveHours + case twelveHours + case day + case week + + var id: String { rawValue } + + /// `nil` keeps it forever. + var interval: TimeInterval? { + switch self { + case .never: return nil + #if DEBUG + case .minute: return 60 + #endif + case .hour: return 3_600 + case .fiveHours: return 5 * 3_600 + case .twelveHours: return 12 * 3_600 + case .day: return 24 * 3_600 + case .week: return 7 * 24 * 3_600 + } + } + + var title: String { + switch self { + case .never: return "Never" + #if DEBUG + case .minute: return "1 Minute (Debug)" + #endif + case .hour: return "1 Hour" + case .fiveHours: return "5 Hours" + case .twelveHours: return "12 Hours" + case .day: return "24 Hours" + case .week: return "7 Days" + } + } +} + /// UserDefaults-backed app settings. Mutating `excludedBundleIDs` (directly or via /// `addExcludedApp`/`removeExcludedApp`) fires `onRulesChange` so the coordinator can /// push a fresh `RulesEngine` into the clipboard monitor. @@ -109,6 +155,8 @@ final class SettingsStore { static let doubleClickToPasteKey = "doubleClickToPaste" static let copySoundKey = "copySound" static let hapticFeedbackKey = "hapticFeedback" + static let hideSecretsKey = "hideSecrets" + static let secretLifetimeKey = "secretLifetime" var retention: RetentionPeriod { didSet { @@ -177,6 +225,27 @@ final class SettingsStore { } @ObservationIgnored var onFloatingShelfChange: ((Bool) -> Void)? + /// Masks secrets on cards, previews and titles (`SecretDetector`). Display only: the + /// stored and pasted text is unchanged. + var hideSecrets: Bool { + didSet { + guard hideSecrets != oldValue else { return } + defaults.set(hideSecrets, forKey: Self.hideSecretsKey) + onHideSecretsChange?(hideSecrets) + } + } + @ObservationIgnored var onHideSecretsChange: ((Bool) -> Void)? + + /// When items containing a secret are deleted. `.never` by default. + var secretLifetime: SecretLifetime { + didSet { + guard secretLifetime != oldValue else { return } + defaults.set(secretLifetime.rawValue, forKey: Self.secretLifetimeKey) + onSecretLifetimeChange?(secretLifetime) + } + } + @ObservationIgnored var onSecretLifetimeChange: ((SecretLifetime) -> Void)? + /// Hides favorites presentation without deleting saved marks or retention protection. var favoritesEnabled: Bool { didSet { @@ -282,6 +351,9 @@ final class SettingsStore { fetchLinkPreviews = (defaults.object(forKey: Self.fetchLinkPreviewsKey) as? Bool) ?? true recognizeImageText = (defaults.object(forKey: Self.recognizeImageTextKey) as? Bool) ?? true hideDuringScreenSharing = (defaults.object(forKey: Self.hideDuringScreenSharingKey) as? Bool) ?? false + hideSecrets = (defaults.object(forKey: Self.hideSecretsKey) as? Bool) ?? true + secretLifetime = defaults.string(forKey: Self.secretLifetimeKey) + .flatMap(SecretLifetime.init(rawValue:)) ?? .never favoritesEnabled = (defaults.object(forKey: Self.favoritesEnabledKey) as? Bool) ?? true floatingShelf = (defaults.object(forKey: Self.floatingShelfKey) as? Bool) ?? false compactShelf = (defaults.object(forKey: Self.compactShelfKey) as? Bool) ?? false diff --git a/Copy/Shelf/ItemCardView.swift b/Copy/Shelf/ItemCardView.swift index b0c23e2..2f78585 100644 --- a/Copy/Shelf/ItemCardView.swift +++ b/Copy/Shelf/ItemCardView.swift @@ -20,6 +20,9 @@ struct ItemCardView: View { var searchQuery: String = "" /// Briefly true after a "Show in History" jump lands on this card — draws an accent ring. var isFlashing: Bool = false + /// Mask secrets in the card's text unless `secretsRevealed`. Pasting uses the real text. + var hideSecrets: Bool = false + var secretsRevealed: Bool = false /// The card's ⌥-digit paste number (1-9), shown as a badge while ⌥ is held; `nil` hides it. var pasteNumber: Int? = nil /// Whether the "Show in History" context item applies (a search/filter is active, or the @@ -42,6 +45,7 @@ struct ItemCardView: View { let onCommitInlineRename: (String) -> Void let onCancelInlineRename: () -> Void let onToggleFavorite: () -> Void + var onToggleSecrets: () -> Void = {} let onAddToPinboard: (Int64) -> Void let onRemoveFromPinboard: () -> Void let onAddToPasteStack: () -> Void @@ -67,6 +71,21 @@ struct ItemCardView: View { } } + private var masksSecrets: Bool { hideSecrets && !secretsRevealed } + + private static let bodyCap = 1_500 + + /// Masking keeps the length, so highlight tokens still line up. + private func displayText(_ text: String, cap: Int) -> String { + masksSecrets ? SecretDetector.maskedPrefix(text, count: cap) : String(text.prefix(cap)) + } + + /// Earns the card its eye button. Only evaluated while hovering. + private var hasSecret: Bool { + guard hideSecrets, isEditable, let text = item.plainText else { return false } + return SecretDetector.maskedPrefix(text, count: Self.bodyCap) != String(text.prefix(Self.bodyCap)) + } + /// File URL(s) still on disk for this item, used to gate the "Quick Look" menu /// item — see `QuickLookController.fileURLs(for:store:)`. private var quickLookURLs: [URL] { @@ -166,6 +185,8 @@ struct ItemCardView: View { if isHovering && !isInlineRenaming { CardHoverActions(favoritesEnabled: favoritesEnabled, isFavorite: item.isFavorite, onToggleFavorite: onToggleFavorite, + secretsRevealed: hasSecret ? secretsRevealed : nil, + onToggleSecrets: onToggleSecrets, // Only offer unpin while viewing a pinboard, where the card // actually belongs to one it can be removed from. onUnpin: currentPinboardID != nil ? onRemoveFromPinboard : nil, @@ -262,7 +283,7 @@ struct ItemCardView: View { private var dragPreview: some View { HStack(spacing: 6) { dragPreviewGlyph - Text(item.displayTitle) + Text(item.displayTitle(hidingSecrets: masksSecrets)) .font(.system(size: 12, weight: .medium)) .lineLimit(1) .truncationMode(.tail) @@ -349,9 +370,8 @@ struct ItemCardView: View { /// drawn on screen. @ViewBuilder private func codeAwareBody(text: String, cap: Int) -> some View { - let capped = String(text.prefix(cap)) let highlight = CodeHighlightCache.shared.result(for: text, uuid: item.uuid, cap: cap) - let display = Self.preservingIndentation(capped) + let display = Self.preservingIndentation(displayText(text, cap: cap)) if highlight.language != nil { highlightedText(display, tokens: highlight.tokens) .font(.system(size: 12, design: .monospaced)) @@ -381,7 +401,7 @@ struct ItemCardView: View { if let hex = HexColor.normalized(item.plainText ?? "") { colorSwatchBody(hex) } else { - codeAwareBody(text: item.plainText ?? "", cap: 1_500) + codeAwareBody(text: item.plainText ?? "", cap: Self.bodyCap) .font(Tokens.cardBody) .lineLimit(bodyLineLimit(standard: 11, compact: 6)) .multilineTextAlignment(.leading) @@ -457,7 +477,7 @@ struct ItemCardView: View { .multilineTextAlignment(.leading) } // Give the URL the remaining body lines while keeping the footer visible. - Text(String((item.plainText ?? "").prefix(1_500))) + Text(displayText(item.plainText ?? "", cap: Self.bodyCap)) .font(Tokens.bodyMono) .foregroundStyle(.secondary) .lineLimit(bodyLineLimit(standard: 8, compact: 3)) @@ -651,12 +671,16 @@ private struct CardClickGesture: ViewModifier { /// The floating action pill shown on card hover (see `ItemCardView`'s top-trailing /// overlay). Surfaces favorite and delete, the two high-value actions otherwise hidden -/// in the right-click menu, plus unpin while viewing a pinboard. Stays a quiet affordance -/// rather than a toolbar; the rest remains in the context menu and via drag. +/// in the right-click menu, plus unpin while viewing a pinboard and the eye that reveals +/// a masked secret. Stays a quiet affordance rather than a toolbar; the rest remains in +/// the context menu and via drag. private struct CardHoverActions: View { let favoritesEnabled: Bool let isFavorite: Bool let onToggleFavorite: () -> Void + /// `nil` hides the eye button: the card has no secret. + var secretsRevealed: Bool? = nil + var onToggleSecrets: () -> Void = {} /// Removes the card from the pinboard currently being viewed. `nil` (and so hidden) /// outside a pinboard, where there's nothing to unpin from. let onUnpin: (() -> Void)? @@ -664,6 +688,12 @@ private struct CardHoverActions: View { var body: some View { HStack(spacing: 1) { + if let secretsRevealed { + IconButton(systemName: secretsRevealed ? "eye.slash" : "eye", + fontSize: 11, size: CGSize(width: 22, height: 22), + help: secretsRevealed ? "Hide secrets" : "Show secrets", + action: onToggleSecrets) + } if favoritesEnabled { IconButton(systemName: isFavorite ? "star.fill" : "star", fontSize: 11, size: CGSize(width: 22, height: 22), diff --git a/Copy/Shelf/PreviewPane.swift b/Copy/Shelf/PreviewPane.swift index 26baa5a..51efeea 100644 --- a/Copy/Shelf/PreviewPane.swift +++ b/Copy/Shelf/PreviewPane.swift @@ -6,6 +6,10 @@ import UniformTypeIdentifiers struct PreviewPane: View { let item: ClipItem let store: ItemStore + /// Mask secrets in the text unless `secretsRevealed`. + var hideSecrets: Bool = false + var secretsRevealed: Bool = false + var onToggleSecrets: () -> Void = {} @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var imagePixelSize: CGSize? @@ -34,6 +38,13 @@ struct PreviewPane: View { /// the paste's actual size. Anything beyond the cap still renders (up to the /// existing 200k display cap below), just without color. private let highlightCap = 10_000 + private static let displayCap = 200_000 + + /// Earns the preview its eye button. + private var hasSecret: Bool { + guard hideSecrets, let text = item.plainText else { return false } + return SecretDetector.maskedPrefix(text, count: Self.displayCap) != String(text.prefix(Self.displayCap)) + } /// Builds the default-branch preview text: the same 200k-character display cap as /// before, with syntax colors applied to only the first `highlightCap` characters @@ -41,7 +52,10 @@ struct PreviewPane: View { /// Any remainder past the highlighted portion still renders, just as plain mono /// text, exactly as the whole thing did before this feature existed. private func codeAwarePreviewText(_ text: String) -> Text { - let displayText = String(text.prefix(200_000)) + // Masking keeps the length, so highlight tokens still line up. + let displayText = hideSecrets && !secretsRevealed + ? SecretDetector.maskedPrefix(text, count: Self.displayCap) + : String(text.prefix(Self.displayCap)) let cap = min(displayText.count, highlightCap) let highlightPortion = String(displayText.prefix(cap)) let highlight = CodeHighlightCache.shared.result(for: text, uuid: item.uuid, cap: cap) @@ -80,6 +94,23 @@ struct PreviewPane: View { .frame(maxWidth: .infinity, alignment: .topLeading) .padding(14) } + .overlay(alignment: .topTrailing) { + if hasSecret { + // Same pill as a card's hover actions. + IconButton(systemName: secretsRevealed ? "eye.slash" : "eye", + fontSize: 11, size: CGSize(width: 22, height: 22), + help: secretsRevealed ? "Hide secrets" : "Show secrets", + action: onToggleSecrets) + .padding(2) + .background( + Capsule(style: .continuous) + .fill(.regularMaterial) + .overlay(Capsule(style: .continuous) + .stroke(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 0.5)) + ) + .padding(8) + } + } } } .frame(width: previewSize.width, height: previewSize.height) diff --git a/Copy/Shelf/ShelfRootView.swift b/Copy/Shelf/ShelfRootView.swift index cf1c5b6..3652be7 100644 --- a/Copy/Shelf/ShelfRootView.swift +++ b/Copy/Shelf/ShelfRootView.swift @@ -563,6 +563,8 @@ private struct ShelfItemsRow: View { compact: compact, searchQuery: viewModel.searchQuery.text, isFlashing: viewModel.flashItemID == item.uuid, + hideSecrets: viewModel.settings.hideSecrets, + secretsRevealed: viewModel.revealedSecretIDs.contains(item.uuid), pasteNumber: (viewModel.optionHeld && index < 9) ? index + 1 : nil, canShowInHistory: !viewModel.searchQuery.isEmpty || currentPinboardID != nil, onClick: { modifiers in viewModel.handleCardClick(item, modifiers: modifiers) }, @@ -581,6 +583,7 @@ private struct ShelfItemsRow: View { onCommitInlineRename: { viewModel.commitInlineRename(item, to: $0) }, onCancelInlineRename: { viewModel.cancelInlineRename() }, onToggleFavorite: { viewModel.toggleFavorite(item) }, + onToggleSecrets: { viewModel.toggleSecretsRevealed(item) }, onAddToPinboard: { id in viewModel.addItem(item, toPinboard: id) }, onRemoveFromPinboard: { if let currentPinboardID { @@ -622,7 +625,10 @@ private struct ShelfItemsRow: View { get: { viewModel.isSelected(item) && item.uuid == viewModel.selection.primary && viewModel.previewShown }, set: { viewModel.previewShown = $0 } )) { - PreviewPane(item: item, store: viewModel.store) + PreviewPane(item: item, store: viewModel.store, + hideSecrets: viewModel.settings.hideSecrets, + secretsRevealed: viewModel.revealedSecretIDs.contains(item.uuid), + onToggleSecrets: { viewModel.toggleSecretsRevealed(item) }) } } } diff --git a/Copy/Shelf/ShelfViewModel.swift b/Copy/Shelf/ShelfViewModel.swift index 314fc93..3761119 100644 --- a/Copy/Shelf/ShelfViewModel.swift +++ b/Copy/Shelf/ShelfViewModel.swift @@ -101,6 +101,8 @@ final class ShelfViewModel { /// The uuid of the card the pointer is currently over, so a force-click can act on /// exactly the card under the cursor (set by `ItemCardView`'s hover callback). var hoveredItemID: String? + /// Cards (by uuid) whose secrets are shown. Cleared when the shelf closes. + private(set) var revealedSecretIDs: Set = [] /// The pinboard tab a dragged tab would land beside, and on which side, so that tab /// shows its insertion edge. Driven by `ShelfHostingView` (see `shelfDragMoved`). var reorderTargetedPinboardID: Int64? @@ -402,6 +404,7 @@ final class ShelfViewModel { showingTips = false commandHeld = false hoveredItemID = nil + revealedSecretIDs = [] if !searchQuery.isEmpty { searchQuery = SearchQuery() } suggestions = [] cachedApps = [] @@ -678,6 +681,19 @@ final class ShelfViewModel { if !searchQuery.isEmpty { refresh() } } + func hidesSecrets(in item: ClipItem) -> Bool { + settings.hideSecrets && !revealedSecretIDs.contains(item.uuid) + } + + /// The eye button on a card or its preview. + func toggleSecretsRevealed(_ item: ClipItem) { + if revealedSecretIDs.contains(item.uuid) { + revealedSecretIDs.remove(item.uuid) + } else { + revealedSecretIDs.insert(item.uuid) + } + } + func toggleFavorite(_ item: ClipItem) { guard settings.favoritesEnabled else { return } guard let id = item.id else { return } diff --git a/CopyCore/Sources/CopyCore/Models/ClipItem+Display.swift b/CopyCore/Sources/CopyCore/Models/ClipItem+Display.swift index 17279d6..156273e 100644 --- a/CopyCore/Sources/CopyCore/Models/ClipItem+Display.swift +++ b/CopyCore/Sources/CopyCore/Models/ClipItem+Display.swift @@ -2,22 +2,30 @@ import Foundation public extension ClipItem { /// One-line human title for menus and compact rows. - var menuTitle: String { + var menuTitle: String { menuTitle(hidingSecrets: false) } + + /// The user-assigned `title` when set, otherwise `menuTitle`. + var displayTitle: String { displayTitle(hidingSecrets: false) } + + /// `menuTitle`, masking secrets when `hidingSecrets`. + func menuTitle(hidingSecrets: Bool) -> String { switch kind { case .image: return "Image" case .file: return plainText?.components(separatedBy: "\n").first ?? "File" default: - let text = String((plainText ?? "").prefix(200)) + let source = plainText ?? "" + let head = hidingSecrets ? SecretDetector.maskedPrefix(source, count: 200) : String(source.prefix(200)) + let text = head .replacingOccurrences(of: "\n", with: " ") .trimmingCharacters(in: .whitespaces) return text.count > 50 ? String(text.prefix(50)) + "…" : text } } - /// The user-assigned `title` when set, otherwise `menuTitle`. - var displayTitle: String { - title?.isEmpty == false ? title! : menuTitle + /// `displayTitle`; a title the user typed is never masked. + func displayTitle(hidingSecrets: Bool) -> String { + title?.isEmpty == false ? title! : menuTitle(hidingSecrets: hidingSecrets) } } diff --git a/CopyCore/Sources/CopyCore/Models/Records.swift b/CopyCore/Sources/CopyCore/Models/Records.swift index 2d7c0d1..adb016f 100644 --- a/CopyCore/Sources/CopyCore/Models/Records.swift +++ b/CopyCore/Sources/CopyCore/Models/Records.swift @@ -18,6 +18,9 @@ public struct ClipItem: Codable, Equatable, Identifiable, FetchableRecord, Mutab public var isFavorite: Bool public var title: String? public var recognizedText: String? + /// Whether the text holds a secret (`SecretDetector`). `nil` until + /// `ItemStore.pruneSecrets` has checked it, and again after an edit. + public var containsSecret: Bool? public mutating func didInsert(_ inserted: InsertionSuccess) { id = inserted.rowID diff --git a/CopyCore/Sources/CopyCore/Rules/SecretDetector.swift b/CopyCore/Sources/CopyCore/Rules/SecretDetector.swift new file mode 100644 index 0000000..5272cd5 --- /dev/null +++ b/CopyCore/Sources/CopyCore/Rules/SecretDetector.swift @@ -0,0 +1,174 @@ +import Foundation + +/// Finds secrets in copied text (API keys, tokens, private keys, card numbers) so they can +/// be shown masked. Conservative on purpose: it would rather miss a secret than mask +/// ordinary text, so an unquoted lowercase `api_key=value` is skipped (code looks the same). +public enum SecretDetector { + public static let maskCharacter: Character = "•" + + public static func containsSecret(_ text: String) -> Bool { + masked(text) != text + } + + /// `containsSecret` without the display cache, for scanning a whole history. + public static func scan(_ text: String) -> Bool { + computeMasked(text) != text + } + + /// `text` with the middle of each secret replaced by `maskCharacter`. The length never + /// changes (characters and UTF-16), so highlight token ranges still line up. + public static func masked(_ text: String) -> String { + let key = text as NSString + if let cached = cache.object(forKey: key) { return cached as String } + let result = computeMasked(text) + cache.setObject(result as NSString, forKey: key) + return result + } + + /// The first `count` characters, masked. Scans past the cutoff so a secret it slices + /// through is still caught. + public static func maskedPrefix(_ text: String, count: Int) -> String { + String(masked(String(text.prefix(count + cutoffMargin))).prefix(count)) + } + + // MARK: - Detection + + private enum Style { + /// Keep a few characters at each end. + case token + /// Keep the last four digits. + case card + /// Hide everything but line breaks (a private key's body). + case block + } + + private struct Rule { + let regex: NSRegularExpression + /// The capture group holding the secret; 0 is the whole match. + let group: Int + let style: Style + } + + private static let cutoffMargin = 4_096 + /// Text past this is not scanned. + private static let scanLimit = 250_000 + private static let maskUnit = maskCharacter.utf16.first! + + private static let cache: NSCache = { + let cache = NSCache() + cache.countLimit = 600 + return cache + }() + + /// Names that mark a `name = value` as a secret. + private static let secretName = "(?:api[_-]?key|secret|token|passw(?:or)?d)" + /// No `.`, `(`, `&` or `$`: property accesses, calls, URL separators, variables. + private static let bareValue = "[A-Za-z0-9_+/=~!@#%^*-]" + /// Needs a letter and a digit, and must not be a path, variable or URL. + private static func looksLikeSecret(_ valueClass: String) -> String { + "(?![/~.$]|https?:)(?=\(valueClass)*[A-Za-z])(?=\(valueClass)*[0-9])" + } + + private static let rules: [Rule] = { + func rule(_ pattern: String, group: Int = 0, style: Style = .token, + options: NSRegularExpression.Options = []) -> Rule { + // Literal patterns: a failure is a typo the tests catch. + Rule(regex: try! NSRegularExpression(pattern: pattern, options: options), group: group, style: style) + } + let quotedValue = "[\\x21\\x23-\\x26\\x28-\\x7E]" // printable ASCII except quotes + return [ + // Provider formats, by prefix. + rule("\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b"), // AWS access key ID + rule("\\bgh[pousr]_[A-Za-z0-9]{36,}\\b"), // GitHub token + rule("\\bgithub_pat_[A-Za-z0-9_]{50,}\\b"), // GitHub fine-grained token + rule("\\bsk-(?=[A-Za-z0-9_-]*[0-9])[A-Za-z0-9_-]{20,}"), // OpenAI, Anthropic + rule("\\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}"), // Stripe + rule("\\bwhsec_[A-Za-z0-9]{24,}"), // Stripe webhook secret + rule("\\bxox[abprs]-[A-Za-z0-9-]{10,}"), // Slack + rule("\\bAIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])"), // Google API key + rule("\\bnpm_[A-Za-z0-9]{36}\\b"), // npm + rule("\\bSG\\.[A-Za-z0-9_-]{16,}\\.[A-Za-z0-9_-]{16,}"), // SendGrid + rule("\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}"), // JWT + rule("\\bbearer\\s+(?=[A-Za-z0-9._~+/=-]*[0-9])([A-Za-z0-9._~+/=-]{20,})", + group: 1, options: [.caseInsensitive]), + // Private key: hide the body (to the end if unterminated), keep BEGIN/END. + rule("-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----([\\s\\S]*?)(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|\\z)", + group: 1, style: .block), + // name = "value" + rule("\(secretName)[A-Za-z0-9_-]{0,40}[\"']?\\s{0,4}[:=]\\s{0,4}[\"']" + + "\(looksLikeSecret(quotedValue))(\(quotedValue){12,})[\"']", + group: 1, options: [.caseInsensitive]), + // NAME=value (.env). Case-sensitive on purpose: lowercase is how code looks. + rule("(? String { + var units = Array(text.utf16) + let source = text as NSString + let scanRange = NSRange(location: 0, length: min(source.length, scanLimit)) + var changed = false + + // Rules run on the original text; overlapping masks combine. + for rule in rules { + for match in rule.regex.matches(in: text, options: [], range: scanRange) { + let range = match.range(at: rule.group) + guard range.location != NSNotFound, range.length > 0 else { continue } + switch rule.style { + case .token: + let keep = range.length >= 16 ? 4 : (range.length >= 8 ? 2 : 0) + for index in (range.location + keep)..<(range.location + range.length - keep) { + units[index] = maskUnit + } + case .card: + let digits = (range.location..<(range.location + range.length)) + .filter { isDigit(source.character(at: $0)) } + guard isCardNumber(digits.map { source.character(at: $0) }) else { continue } + for index in digits.dropLast(4) { units[index] = maskUnit } + case .block: + for index in range.location..<(range.location + range.length) + where !isLineBreak(source.character(at: index)) { + units[index] = maskUnit + } + } + changed = true + } + } + return changed ? String(utf16CodeUnits: units, count: units.count) : text + } + + private static func isDigit(_ unit: unichar) -> Bool { unit >= 48 && unit <= 57 } + private static func isLineBreak(_ unit: unichar) -> Bool { unit == 10 || unit == 13 } + + /// A known issuer prefix plus a valid Luhn checksum. + private static func isCardNumber(_ units: [unichar]) -> Bool { + let digits = units.map { Int($0) - 48 } + let firstTwo = digits[0] * 10 + digits[1] + switch digits.count { + case 15: + guard firstTwo == 34 || firstTwo == 37 else { return false } // American Express + case 16: + let knownIssuer = digits[0] == 4 // Visa + || (51...55).contains(firstTwo) || (22...27).contains(firstTwo) // Mastercard + || firstTwo == 35 || digits[0] == 6 // JCB, Discover + guard knownIssuer else { return false } + default: + return false + } + var sum = 0 + for (offset, digit) in digits.reversed().enumerated() { + let doubled = offset % 2 == 1 ? digit * 2 : digit + sum += doubled > 9 ? doubled - 9 : doubled + } + return sum % 10 == 0 + } +} diff --git a/CopyCore/Sources/CopyCore/Storage/DatabaseManager.swift b/CopyCore/Sources/CopyCore/Storage/DatabaseManager.swift index 70405aa..b5f1976 100644 --- a/CopyCore/Sources/CopyCore/Storage/DatabaseManager.swift +++ b/CopyCore/Sources/CopyCore/Storage/DatabaseManager.swift @@ -106,6 +106,12 @@ public final class DatabaseManager { t.add(column: "emoji", .text) } } + migrator.registerMigration("v4") { db in + // NULL = not checked yet; `ItemStore.pruneSecrets` fills it in. + try db.alter(table: "item") { t in + t.add(column: "containsSecret", .boolean) + } + } return migrator } } diff --git a/CopyCore/Sources/CopyCore/Storage/ItemStore.swift b/CopyCore/Sources/CopyCore/Storage/ItemStore.swift index 26fd228..eb75f3c 100644 --- a/CopyCore/Sources/CopyCore/Storage/ItemStore.swift +++ b/CopyCore/Sources/CopyCore/Storage/ItemStore.swift @@ -565,6 +565,7 @@ public struct ItemStore { item.linkTitle = nil item.contentHash = hash item.sizeBytes = text.utf8.count + item.containsSecret = nil item.lastUsedAt = now try item.update(db) try insertRepresentations( @@ -606,6 +607,7 @@ public struct ItemStore { item.linkTitle = nil item.contentHash = hash item.sizeBytes = plainText.utf8.count + item.containsSecret = nil item.lastUsedAt = now try item.update(db) try insertRepresentations( @@ -663,6 +665,51 @@ public struct ItemStore { } } + /// Deletes items that contain a secret and were first copied before `cutoff`, sparing + /// favorites and pinboard members. Unchecked items (`containsSecret` NULL) are + /// classified first. + @discardableResult + public func pruneSecrets(olderThan cutoff: Date) throws -> Int { + try classifyUncheckedItems() + return try writer.write { db in + let doomed = try Int64.fetchAll(db, sql: """ + SELECT id FROM item + WHERE containsSecret = true AND createdAt < ? AND isFavorite = false + AND id NOT IN (SELECT DISTINCT itemId FROM pinboard_item) + """, arguments: [cutoff]) + guard !doomed.isEmpty else { return 0 } + try deleteItems(ClipItem.filter(doomed.contains(Column("id"))), in: db) + return doomed.count + } + } + + /// Fills in `containsSecret` where it is NULL, in batches. Only text is scanned. + private func classifyUncheckedItems() throws { + let textKinds = [ItemKind.text, .richText, .link].map(\.rawValue) + try writer.write { db in + try db.execute(sql: """ + UPDATE item SET containsSecret = false + WHERE containsSecret IS NULL AND (plainText IS NULL OR kind NOT IN (?, ?, ?)) + """, arguments: StatementArguments(textKinds)) + } + while true { + // One transaction per batch, so an edit can't slip in between. + let classified = try writer.write { db -> Int in + let rows = try Row.fetchAll(db, sql: """ + SELECT id, plainText FROM item WHERE containsSecret IS NULL LIMIT ? + """, arguments: [Self.deleteChunkSize]) + for row in rows { + let id: Int64 = row["id"] + let text: String = row["plainText"] + try db.execute(sql: "UPDATE item SET containsSecret = ? WHERE id = ?", + arguments: [SecretDetector.scan(text), id]) + } + return rows.count + } + if classified == 0 { break } + } + } + public func setLinkTitle(itemID: Int64, _ title: String?) throws { try writer.write { db in try db.execute( diff --git a/CopyCore/Tests/CopyCoreTests/SecretDetectorTests.swift b/CopyCore/Tests/CopyCoreTests/SecretDetectorTests.swift new file mode 100644 index 0000000..1443bfd --- /dev/null +++ b/CopyCore/Tests/CopyCoreTests/SecretDetectorTests.swift @@ -0,0 +1,178 @@ +import XCTest +@testable import CopyCore + +/// Every key here is fake, assembled from pieces so secret scanners ignore it. +final class SecretDetectorTests: XCTestCase { + private let awsKey = "AKIA" + "IOSFODNN7EXAMPLE" + private let githubToken = "gh" + "p_" + "abcdefghijklmnopqrstuvwxyz0123456789" + private let openAIKey = "sk" + "-proj-" + "FAKEfakeFAKEfake1234567890abcd" + private let stripeKey = "sk" + "_test_" + "FAKE0000FAKE0000FAKE0000" + private let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + + ".eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0" + + ".SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + + private func mask(_ text: String) -> String { SecretDetector.masked(text) } + private let dot = String(SecretDetector.maskCharacter) + + // MARK: Provider formats + + func testMasksProviderKeysKeepingBothEnds() { + XCTAssertEqual(mask(awsKey), "AKIA" + String(repeating: dot, count: 12) + "MPLE") + XCTAssertEqual(mask(githubToken), "ghp_" + String(repeating: dot, count: 32) + "6789") + XCTAssertTrue(mask(openAIKey).hasPrefix("sk-p" + dot)) + XCTAssertTrue(mask(openAIKey).hasSuffix(dot + "abcd")) + XCTAssertTrue(SecretDetector.containsSecret(stripeKey)) + XCTAssertTrue(SecretDetector.containsSecret(jwt)) + } + + func testMasksOnlyTheSecretInsideSurroundingText() { + let text = "Use \(awsKey) for the staging bucket." + XCTAssertEqual(mask(text), "Use AKIA" + String(repeating: dot, count: 12) + "MPLE for the staging bucket.") + } + + func testBearerTokenIsMaskedButTheHeaderNameStays() { + let masked = mask("Authorization: Bearer abcdef0123456789abcdef0123456789") + XCTAssertTrue(masked.hasPrefix("Authorization: Bearer abcd" + dot)) + XCTAssertFalse(masked.contains("0123456789abcdef")) + } + + // MARK: name = value + + func testMasksValueOfEnvStyleAssignmentButNotItsName() { + let masked = mask("DB_PASSWORD=Tr0ub4dor-and-3-more") + XCTAssertTrue(masked.hasPrefix("DB_PASSWORD=Tr0u" + dot)) + XCTAssertTrue(masked.hasSuffix(dot + "more")) + } + + func testMasksQuotedValueInJSONAndCode() { + XCTAssertTrue(SecretDetector.containsSecret(#"{ "api_key": "a1b2c3d4e5f6g7h8" }"#)) + XCTAssertTrue(SecretDetector.containsSecret(#"let clientSecret = "a1b2c3d4e5f6g7h8""#)) + XCTAssertTrue(mask(#"password: 'hunter2hunter2'"#).hasPrefix("password: 'hu" + dot)) + } + + func testMasksTokenInURLQueryButKeepsTheRestOfTheURL() { + let masked = mask("https://example.com/hook?access_token=a1b2c3d4e5f6g7h8&page=2") + XCTAssertTrue(masked.hasPrefix("https://example.com/hook?access_token=a1b2" + dot)) + XCTAssertTrue(masked.hasSuffix("g7h8&page=2")) + } + + func testMasksEachSecretInAMultiLineEnvFile() { + let env = "APP_NAME=Copy\nAPI_KEY=\(openAIKey)\nSTRIPE_SECRET=\(stripeKey)\nDEBUG=true" + let lines = mask(env).components(separatedBy: "\n") + XCTAssertEqual(lines[0], "APP_NAME=Copy") + XCTAssertTrue(lines[1].hasPrefix("API_KEY=sk-p" + dot)) + XCTAssertTrue(lines[2].hasPrefix("STRIPE_SECRET=sk_t" + dot)) + XCTAssertEqual(lines[3], "DEBUG=true") + } + + // MARK: Cards and private keys + + func testCardNumberKeepsOnlyLastFourDigitsAndItsSpacing() { + let group = String(repeating: dot, count: 4) + XCTAssertEqual(mask("4242 4242 4242 4242"), "\(group) \(group) \(group) 4242") + XCTAssertEqual(mask("Card: 4111-1111-1111-1111."), "Card: \(group)-\(group)-\(group)-1111.") + XCTAssertEqual(mask("378282246310005"), String(repeating: dot, count: 11) + "0005") // Amex, 15 digits + } + + func testDigitRunsThatAreNotCardsStayVisible() { + for text in [ + "1234 5678 9012 3456", // unknown issuer prefix + "4242 4242 4242 4241", // right prefix, wrong checksum + "1728561234567", // 13 digits: a millisecond timestamp + "42424242424242424242", // 20 digits + "+1 (555) 010-1234", + "Order 4242424242424242x", // runs into a letter + ] { + XCTAssertEqual(mask(text), text, text) + } + } + + func testPrivateKeyBodyIsHiddenAndItsLinesKept() { + let begin = "-----BEGIN OPENSSH PRIVATE KEY-----", end = "-----END OPENSSH PRIVATE KEY-----" + let hidden = String(repeating: dot, count: 15) + XCTAssertEqual(mask("\(begin)\nb3BlbnNzaC1rZXk\nAAAAB3NzaC1yc2E\n\(end)"), + "\(begin)\n\(hidden)\n\(hidden)\n\(end)") + } + + func testUnterminatedPrivateKeyIsHiddenToTheEnd() { + XCTAssertEqual(mask("-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg"), + "-----BEGIN PRIVATE KEY-----\n" + String(repeating: dot, count: 14)) + } + + // MARK: Ordinary text + + func testOrdinaryTextIsLeftAlone() { + for text in [ + "9d352d5a1b2c3d4e5f60718293a4b5c6d7e8f901", // git commit hash + "6F9619FF-8B86-D011-B42D-00C04FC964FF", // UUID + "#4C9DFF", + "https://github.com/tarikbc/Copy/pull/18", + "my password is on a sticky note", + "The token: please bring your ticket to the desk", + "Standup at 10:30, demo the smart search and paste stack.", + "the task-management-system-design-document", // "sk-" inside a word + "", + ] { + XCTAssertFalse(SecretDetector.containsSecret(text), text) + } + } + + /// Identifiers, labels, paths and references are not secret values. + func testCodeThatOnlyNamesASecretIsLeftAlone() { + for text in [ + #"let passwordLabel = "Enter your password""#, // quoted, but prose + "login(password=user_password1)", // lowercase kwarg + "token = session2Token", // spaced assignment to an identifier + "password = hashPassword(input1)", + "let apiKey = config.value(for: key2)", + "SECRET_PATH=/etc/app/secrets2", // a path + "API_KEY=$OPENAI_API_KEY_V2", // a variable reference + #"callbackToken = "https://example.com/v2/callback""#, // a URL + "DEBUG_TOKEN=true", // too short, no digit + ] { + XCTAssertEqual(mask(text), text, text) + } + } + + // MARK: Titles + + func testDisplayTitleMasksSecretsOnlyWhenAskedAndNeverATitleTheUserTyped() { + var item = ClipItem( + id: 1, uuid: "u", kind: .text, createdAt: Date(), lastUsedAt: Date(), + plainText: awsKey, linkTitle: nil, appBundleID: nil, appName: nil, + contentHash: "h", sizeBytes: 20, isFavorite: false) + + XCTAssertEqual(item.displayTitle, awsKey) + XCTAssertEqual(item.displayTitle(hidingSecrets: false), awsKey) + XCTAssertEqual(item.displayTitle(hidingSecrets: true), mask(awsKey)) + + item.title = "AWS staging key" + XCTAssertEqual(item.displayTitle(hidingSecrets: true), "AWS staging key") + } + + // MARK: Guarantees the UI relies on + + func testMaskingNeverChangesTheLength() { + for text in [awsKey, githubToken, jwt, "API_KEY=\(openAIKey)", "4242 4242 4242 4242", + "héllo 👋 \(stripeKey) wörld", "-----BEGIN PRIVATE KEY-----\r\nabc\r\ndef\r\n"] { + let masked = mask(text) + XCTAssertEqual(masked.count, text.count, text) + XCTAssertEqual(masked.utf16.count, text.utf16.count, text) + } + } + + func testMaskedPrefixHidesASecretTheCutoffSlicesThrough() { + let text = "note " + githubToken + // The cutoff lands 10 characters into the token; none of its hidden part may show. + XCTAssertEqual(SecretDetector.maskedPrefix(text, count: 15), "note ghp_" + String(repeating: dot, count: 6)) + XCTAssertEqual(SecretDetector.maskedPrefix("plain text", count: 5), "plain") + } + + /// Guards against quadratic backtracking on a long run of key-like characters. + func testAHugeRunOfKeyLikeCharactersDoesNotHang() { + let blob = String(repeating: "aGVsbG8gd29ybGQ", count: 20_000) // 300k characters + let started = Date() + XCTAssertFalse(SecretDetector.containsSecret(blob)) + XCTAssertLessThan(Date().timeIntervalSince(started), 2) + } +} diff --git a/CopyCore/Tests/CopyCoreTests/SecretRetentionTests.swift b/CopyCore/Tests/CopyCoreTests/SecretRetentionTests.swift new file mode 100644 index 0000000..51dae64 --- /dev/null +++ b/CopyCore/Tests/CopyCoreTests/SecretRetentionTests.swift @@ -0,0 +1,96 @@ +import XCTest +@testable import CopyCore + +/// `ItemStore.pruneSecrets`. Every key here is fake (see `SecretDetectorTests`). +final class SecretRetentionTests: XCTestCase { + private let secret = "API_KEY=" + "sk" + "-proj-" + "FAKEfakeFAKEfake1234567890abcd" + private let otherSecret = "gh" + "p_" + "abcdefghijklmnopqrstuvwxyz0123456789" + + private let copiedAt = Date(timeIntervalSince1970: 1_000) + private let cutoff = Date(timeIntervalSince1970: 2_000) + private let afterCutoff = Date(timeIntervalSince1970: 3_000) + + private func texts(in store: ItemStore) throws -> Set { + Set(try store.recentItems(limit: 100).compactMap(\.plainText)) + } + + func testDeletesOldSecretsAndNothingElse() throws { + let store = try makeTempStore() + try store.save(makeText(secret), now: copiedAt) + try store.save(makeText("an old note with nothing to hide"), now: copiedAt) + try store.save(makeText(otherSecret), now: afterCutoff) + + XCTAssertEqual(try store.pruneSecrets(olderThan: cutoff), 1) + XCTAssertEqual(try texts(in: store), ["an old note with nothing to hide", otherSecret]) + } + + func testSparesFavoritesAndPinboardMembers() throws { + let (store, pinboards) = try makeTempStores() + let favorite = try store.save(makeText(secret), now: copiedAt) + let pinned = try store.save(makeText(otherSecret), now: copiedAt) + try store.setFavorite(itemID: favorite.id!, true) + let board = try pinboards.create(name: "Keys", symbol: "key") + try pinboards.add(itemID: pinned.id!, to: board.id!) + + XCTAssertEqual(try store.pruneSecrets(olderThan: cutoff), 0) + XCTAssertEqual(try texts(in: store), [secret, otherSecret]) + + // Letting go of the favorite makes it deletable again. + try store.setFavorite(itemID: favorite.id!, false) + XCTAssertEqual(try store.pruneSecrets(olderThan: cutoff), 1) + XCTAssertEqual(try texts(in: store), [otherSecret]) + } + + /// The clock is when the item was first copied; using or re-copying it doesn't extend it. + func testPastingOrCopyingAgainDoesNotExtendTheClock() throws { + let store = try makeTempStore() + let pasted = try store.save(makeText(secret), now: copiedAt) + try store.touch(itemID: pasted.id!, now: afterCutoff) + try store.save(makeText(otherSecret), now: copiedAt) + try store.save(makeText(otherSecret), now: afterCutoff) // same content: dedups, bumps lastUsedAt + + XCTAssertEqual(try store.pruneSecrets(olderThan: cutoff), 2) + XCTAssertTrue(try texts(in: store).isEmpty) + } + + func testAnEditIsCheckedAgain() throws { + let store = try makeTempStore() + let becomesSecret = try store.save(makeText("just a note"), now: copiedAt) + let stopsBeingSecret = try store.save(makeText(secret), now: copiedAt) + + // A first run settles both, with nothing old enough to delete. + XCTAssertEqual(try store.pruneSecrets(olderThan: Date(timeIntervalSince1970: 500)), 0) + + try store.replaceContent(itemID: becomesSecret.id!, with: otherSecret, now: afterCutoff) + try store.replaceContent(itemID: stopsBeingSecret.id!, with: "the key was rotated", now: afterCutoff) + + XCTAssertEqual(try store.pruneSecrets(olderThan: cutoff), 1) + XCTAssertEqual(try texts(in: store), ["the key was rotated"]) + } + + func testClassifiesAHistoryLargerThanOneBatch() throws { + let store = try makeTempStore() + let total = ItemStore.deleteChunkSize * 2 + 25 + for index in 0..