diff --git a/Sources/ICloudCLICore/AppleMetadataInventories.swift b/Sources/ICloudCLICore/AppleMetadataInventories.swift index 3eb24aa..323d142 100644 --- a/Sources/ICloudCLICore/AppleMetadataInventories.swift +++ b/Sources/ICloudCLICore/AppleMetadataInventories.swift @@ -82,9 +82,27 @@ public struct LocalMetadataStoreReader: Sendable { if command == .safariCloudTabsList { return try cloudTabRows(options: options) } + if command == .calendarAccounts || command == .calendarList || command == .calendarEvents { + return try calendarRows(for: command, options: options) + } + if command == .booksCollections || command == .booksList { + return try booksRows(for: command, options: options) + } if command == .mailAccounts || command == .mailMailboxes || command == .mailRecent { return try mailRows(for: command, options: options) } + if command == .notesAccounts || command == .notesFolders || command == .notesTags || command == .notesShared { + return try notesRows(for: command, options: options) + } + if command == .photosSharedAlbums || command == .photosSharedLibrary { + return try photosRows(for: command, options: options) + } + if command == .remindersAssigned || command == .remindersFlagged || command == .remindersScheduled || command == .remindersToday { + return try reminderRows(for: command, options: options) + } + if command == .homeAccessories || command == .homeHomes || command == .homeRooms || command == .homeScenes { + return try homeRows(for: command, options: options) + } if command == .musicStatus || command == .musicPlaylists || command == .musicTracks { try validateSQLiteStore(featureName: "Music library") } @@ -98,25 +116,29 @@ public struct LocalMetadataStoreReader: Sendable { } private func cloudTabRows(options: MetadataOptions) throws -> [MetadataRow] { + guard try tableExists("cloud_tabs"), try tableExists("cloud_tab_devices") else { + throw LocalInventoryError.unsupportedSchema(store: database.path, detail: "missing Safari cloud_tabs or cloud_tab_devices tables") + } var filters: [String] = [] if let device = options.device { filters.append("d.device_name = '\(sqlEscape(device))'") } + let urlExpression = options.includeURLs ? "CAST(t.url AS TEXT)" : "NULL" let whereSQL = filters.isEmpty ? "" : " WHERE " + filters.joined(separator: " AND ") let sql = """ SELECT - d.device_name AS deviceName, - t.title AS title, - t.url AS url, - d.last_modified AS lastSyncedAt, - t.position AS position, - t.is_pinned AS isPinned, - t.is_showing_reader AS isShowingReader, - t.scene_id AS sceneID + CAST(d.device_name AS TEXT) AS deviceName, + CAST(t.title AS TEXT) AS title, + \(urlExpression) AS url, + CAST(d.last_modified AS TEXT) AS lastSyncedAt, + CASE WHEN typeof(t.position) = 'integer' THEN t.position ELSE 0 END AS position, + COALESCE(t.is_pinned, 0) AS isPinned, + COALESCE(t.is_showing_reader, 0) AS isShowingReader, + CAST(t.scene_id AS TEXT) AS sceneID FROM cloud_tabs t LEFT JOIN cloud_tab_devices d ON d.device_uuid = t.device_uuid \(whereSQL) - ORDER BY d.device_name ASC, t.position ASC + ORDER BY d.device_name ASC, position ASC LIMIT \(bounded(options.limit, defaultValue: 50, max: 1000)); """ return try query(sql) @@ -124,6 +146,149 @@ public struct LocalMetadataStoreReader: Sendable { .map { redact(row: $0, command: .safariCloudTabsList, options: options) } } + private func calendarRows(for command: MetadataCommand, options: MetadataOptions) throws -> [MetadataRow] { + if try tableExists(command.tableName ?? "") { + guard let tableName = command.tableName else { return [] } + return try query("SELECT * FROM \(tableName)\(whereClause(for: command, options: options))\(orderClause(for: command)) LIMIT \(bounded(options.limit, defaultValue: 50, max: 1000));") + .map { MetadataRow(kind: command.displayName, fields: $0) } + .map { redact(row: $0, command: command, options: options) } + } + guard try tableExists("Calendar"), try tableExists("Store") else { + throw LocalInventoryError.unsupportedSchema(store: database.path, detail: "missing calendar metadata tables or Apple Calendar tables") + } + switch command { + case .calendarAccounts: + return try query(""" + SELECT + COALESCE(NULLIF(s.name, ''), 'Calendar Account ' || s.ROWID) AS name, + CASE s.type + WHEN 1 THEN 'Local' + WHEN 2 THEN 'Exchange' + WHEN 3 THEN 'CalDAV' + WHEN 4 THEN 'Subscribed' + ELSE CAST(s.type AS TEXT) + END AS type, + COUNT(c.ROWID) AS calendarCount + FROM Store s + LEFT JOIN Calendar c ON c.store_id = s.ROWID + WHERE COALESCE(s.disabled, 0) = 0 + GROUP BY s.ROWID + ORDER BY name ASC + LIMIT \(bounded(options.limit, defaultValue: 50, max: 1000)); + """).map { MetadataRow(kind: command.displayName, fields: $0) } + case .calendarList: + return try query(""" + SELECT + COALESCE(NULLIF(c.title, ''), 'Calendar ' || c.ROWID) AS title, + COALESCE(NULLIF(s.name, ''), 'Calendar Account ' || s.ROWID) AS account, + c.color AS color, + CASE c.type + WHEN 'com.apple.ical.sources.local' THEN 'Local' + WHEN 'com.apple.ical.sources.caldav' THEN 'CalDAV' + ELSE c.type + END AS source, + CASE WHEN c.ROWID = s.delegated_account_default_calendar_for_new_events_id THEN 1 ELSE 0 END AS isDefault + FROM Calendar c + LEFT JOIN Store s ON s.ROWID = c.store_id + WHERE COALESCE(c.flags, 0) >= 0 + ORDER BY account ASC, title ASC + LIMIT \(bounded(options.limit, defaultValue: 50, max: 1000)); + """).map { MetadataRow(kind: command.displayName, fields: $0) } + case .calendarEvents: + guard try tableExists("CalendarItem") else { + throw LocalInventoryError.unsupportedSchema(store: database.path, detail: "missing Apple Calendar CalendarItem table") + } + let startsAt = appleDateExpression("i.start_date") + let endsAt = appleDateExpression("i.end_date") + let since = options.since ?? ISO8601DateFormatter().string(from: Date(timeIntervalSinceNow: -86_400)) + let filters = andClause([ + "COALESCE(i.hidden, 0) = 0", + "i.start_date IS NOT NULL", + "\(startsAt) >= '\(sqlEscape(since))'", + options.until.map { "\(startsAt) <= '\(sqlEscape($0))'" }, + options.calendar.map { "c.title = '\(sqlEscape($0))'" }, + ]) + let participantSubquery = try tableExists("Participant") ? """ + (SELECT group_concat(COALESCE(NULLIF(p.email, ''), NULLIF(p.phone_number, ''), 'participant'), ',') + FROM Participant p + WHERE p.owner_id = i.ROWID) + """ : "NULL" + return try query(""" + SELECT + COALESCE(NULLIF(i.summary, ''), 'Event ' || i.ROWID) AS title, + COALESCE(NULLIF(c.title, ''), 'Calendar ' || c.ROWID) AS calendar, + \(startsAt) AS startsAt, + \(endsAt) AS endsAt, + COALESCE(i.all_day, 0) AS isAllDay, + \(participantSubquery) AS attendees, + i.description AS notes + FROM CalendarItem i + LEFT JOIN Calendar c ON c.ROWID = i.calendar_id + \(filters) + ORDER BY i.start_date ASC + LIMIT \(bounded(options.limit, defaultValue: 50, max: 1000)); + """) + .map { MetadataRow(kind: command.displayName, fields: $0) } + .map { redact(row: $0, command: command, options: options) } + default: + return [] + } + } + + private func booksRows(for command: MetadataCommand, options: MetadataOptions) throws -> [MetadataRow] { + if try tableExists(command.tableName ?? "") { + guard let tableName = command.tableName else { return [] } + return try query("SELECT * FROM \(tableName)\(whereClause(for: command, options: options))\(orderClause(for: command)) LIMIT \(bounded(options.limit, defaultValue: 50, max: 1000));") + .map { MetadataRow(kind: command.displayName, fields: $0) } + .map { redact(row: $0, command: command, options: options) } + } + guard try tableExists("ZBKLIBRARYASSET") else { + throw LocalInventoryError.unsupportedSchema(store: database.path, detail: "missing books or ZBKLIBRARYASSET tables") + } + switch command { + case .booksCollections: + guard try tableExists("ZBKCOLLECTION") else { return [] } + return try query(""" + SELECT + COALESCE(NULLIF(c.ZTITLE, ''), c.ZCOLLECTIONID, 'Collection ' || c.Z_PK) AS name, + COUNT(m.Z_PK) AS bookCount + FROM ZBKCOLLECTION c + LEFT JOIN ZBKCOLLECTIONMEMBER m ON m.ZCOLLECTION = c.Z_PK + WHERE COALESCE(c.ZDELETEDFLAG, 0) = 0 + GROUP BY c.Z_PK + ORDER BY name ASC + LIMIT \(bounded(options.limit, defaultValue: 50, max: 1000)); + """).map { MetadataRow(kind: command.displayName, fields: $0) } + case .booksList: + let hasCollections = try tableExists("ZBKCOLLECTION") + let hasCollectionMembers = try tableExists("ZBKCOLLECTIONMEMBER") + let hasCollectionJoin = hasCollections && hasCollectionMembers + let collectionJoin = hasCollectionJoin ? """ + LEFT JOIN ZBKCOLLECTIONMEMBER m ON m.ZASSET = a.Z_PK + LEFT JOIN ZBKCOLLECTION c ON c.Z_PK = m.ZCOLLECTION + """ : "" + let collectionColumn = collectionJoin.isEmpty ? "NULL" : "c.ZTITLE" + let collectionFilter = options.collection.map { " AND \(collectionColumn) = '\(sqlEscape($0))'" } ?? "" + let rows = try query(""" + SELECT + COALESCE(NULLIF(a.ZTITLE, ''), a.ZASSETID, 'Book ' || a.Z_PK) AS title, + a.ZAUTHOR AS author, + a.ZKIND AS format, + \(collectionColumn) AS collection, + CAST(a.ZREADINGPROGRESS * 100 AS INTEGER) AS progressPercent, + NULL AS highlightCount + FROM ZBKLIBRARYASSET a + \(collectionJoin) + WHERE COALESCE(a.ZISHIDDEN, 0) = 0\(collectionFilter) + ORDER BY title ASC + LIMIT \(bounded(options.limit, defaultValue: 50, max: 1000)); + """).map { MetadataRow(kind: command.displayName, fields: $0) } + return rows.map { redact(row: $0, command: command, options: options) } + default: + return [] + } + } + private func mailRows(for command: MetadataCommand, options: MetadataOptions) throws -> [MetadataRow] { if try tableExists(command.tableName ?? "") { guard let tableName = command.tableName else { return [] } @@ -147,6 +312,286 @@ public struct LocalMetadataStoreReader: Sendable { } } + private func notesRows(for command: MetadataCommand, options: MetadataOptions) throws -> [MetadataRow] { + if try tableExists(command.tableName ?? "") { + guard let tableName = command.tableName else { return [] } + return try query("SELECT * FROM \(tableName)\(whereClause(for: command, options: options))\(orderClause(for: command)) LIMIT \(bounded(options.limit, defaultValue: 50, max: 1000));") + .map { MetadataRow(kind: command.displayName, fields: $0) } + } + guard try tableExists("ZICCLOUDSYNCINGOBJECT") else { + throw LocalInventoryError.unsupportedSchema(store: database.path, detail: "missing notes metadata tables or ZICCLOUDSYNCINGOBJECT table") + } + switch command { + case .notesAccounts: + let columns = try columns(in: "ZICCLOUDSYNCINGOBJECT") + let accountExpression = columns.contains("ZACCOUNTNAMEFORACCOUNTLISTSORTING") ? "COALESCE(NULLIF(ZACCOUNTNAMEFORACCOUNTLISTSORTING, ''), 'Local Notes')" : "'Local Notes'" + let folderPredicate = columns.contains("ZNAME") ? "(ZTITLE2 IS NOT NULL OR ZNAME IS NOT NULL)" : "ZTITLE2 IS NOT NULL" + return try query(""" + SELECT + \(accountExpression) AS name, + COUNT(CASE WHEN ZTITLE1 IS NOT NULL THEN 1 END) AS noteCount, + COUNT(CASE WHEN \(folderPredicate) THEN 1 END) AS folderCount + FROM ZICCLOUDSYNCINGOBJECT + WHERE COALESCE(ZMARKEDFORDELETION, 0) = 0 + GROUP BY name + ORDER BY name ASC + LIMIT \(bounded(options.limit, defaultValue: 50, max: 1000)); + """).map { MetadataRow(kind: command.displayName, fields: $0) } + case .notesFolders: + let columns = try columns(in: "ZICCLOUDSYNCINGOBJECT") + let nameCandidates = [ + columns.contains("ZTITLE2") ? "NULLIF(f.ZTITLE2, '')" : nil, + columns.contains("ZNAME") ? "NULLIF(f.ZNAME, '')" : nil, + "'Folder ' || f.Z_PK", + ].compactMap { $0 } + let nameExpression = "COALESCE(\(nameCandidates.joined(separator: ", ")))" + let accountExpression = columns.contains("ZACCOUNTNAMEFORACCOUNTLISTSORTING") ? "f.ZACCOUNTNAMEFORACCOUNTLISTSORTING" : "NULL" + let accountFilter = options.account.map { " AND \(accountExpression) = '\(sqlEscape($0))'" } ?? "" + let sharedExpression = columns.contains("ZISSHAREDIRTY") ? "COALESCE(f.ZISSHAREDIRTY, 0)" : "0" + let folderPredicate = columns.contains("ZNAME") ? "(f.ZTITLE2 IS NOT NULL OR f.ZNAME IS NOT NULL)" : "f.ZTITLE2 IS NOT NULL" + return try query(""" + SELECT + \(nameExpression) AS name, + \(accountExpression) AS account, + COUNT(n.Z_PK) AS noteCount, + \(sharedExpression) AS shared + FROM ZICCLOUDSYNCINGOBJECT f + LEFT JOIN ZICCLOUDSYNCINGOBJECT n ON n.ZFOLDER = f.Z_PK AND n.ZTITLE1 IS NOT NULL AND COALESCE(n.ZMARKEDFORDELETION, 0) = 0 + WHERE \(folderPredicate)\(accountFilter) + AND COALESCE(f.ZMARKEDFORDELETION, 0) = 0 + GROUP BY f.Z_PK + ORDER BY name ASC + LIMIT \(bounded(options.limit, defaultValue: 50, max: 1000)); + """).map { MetadataRow(kind: command.displayName, fields: $0) } + case .notesTags: + return [] + case .notesShared: + guard try tableExists("ZICINVITATION") else { return [] } + return try query(""" + SELECT + COALESCE(NULLIF(ZTITLE, ''), 'Shared Note') AS title, + COALESCE(ZNOTECOUNT, 0) AS noteCount + FROM ZICINVITATION + ORDER BY title ASC + LIMIT \(bounded(options.limit, defaultValue: 50, max: 1000)); + """).map { MetadataRow(kind: command.displayName, fields: $0) } + default: + return [] + } + } + + private func photosRows(for command: MetadataCommand, options: MetadataOptions) throws -> [MetadataRow] { + if try tableExists(command.tableName ?? "") { + guard let tableName = command.tableName else { return [] } + return try query("SELECT * FROM \(tableName)\(whereClause(for: command, options: options))\(orderClause(for: command)) LIMIT \(bounded(options.limit, defaultValue: 50, max: 1000));") + .map { MetadataRow(kind: command.displayName, fields: $0) } + } + switch command { + case .photosSharedAlbums: + guard try tableExists("ZGENERICALBUM") else { + throw LocalInventoryError.unsupportedSchema(store: database.path, detail: "missing photos_shared_albums or ZGENERICALBUM tables") + } + let columns = try columns(in: "ZGENERICALBUM") + let titleExpression = coalesceRawExpression(candidates: ["ZTITLE", "ZCLOUDGUID", "ZUUID"], columns: columns, fallback: "'Shared Album ' || Z_PK") + let assetCountExpression = coalesceRawExpression(candidates: ["ZCACHEDCOUNT", "ZCACHEDPHOTOSCOUNT"], columns: columns, fallback: "0") + let ownerExpression = columns.contains("ZCLOUDOWNERFULLNAME") ? "ZCLOUDOWNERFULLNAME" : "NULL" + let collaborationExpression = columns.contains("ZCLOUDMULTIPLECONTRIBUTORSENABLED") ? "COALESCE(ZCLOUDMULTIPLECONTRIBUTORSENABLED, 0)" : "0" + let updatedExpression = columns.contains("ZCLOUDLASTCONTRIBUTIONDATE") ? "CASE WHEN ZCLOUDLASTCONTRIBUTIONDATE IS NULL THEN NULL ELSE strftime('%Y-%m-%dT%H:%M:%SZ', ZCLOUDLASTCONTRIBUTIONDATE + 978307200, 'unixepoch') END" : "NULL" + let cloudPredicate = columns.contains("ZCLOUDGUID") ? "ZCLOUDGUID IS NOT NULL" : "1 = 1" + let trashedPredicate = columns.contains("ZTRASHEDSTATE") ? "COALESCE(ZTRASHEDSTATE, 0) = 0" : "1 = 1" + return try query(""" + SELECT + \(titleExpression) AS title, + \(ownerExpression) AS owner, + \(collaborationExpression) AS collaborationEnabled, + \(assetCountExpression) AS assetCount, + \(updatedExpression) AS updatedAt + FROM ZGENERICALBUM + WHERE \(cloudPredicate) + AND \(trashedPredicate) + ORDER BY title ASC + LIMIT \(bounded(options.limit, defaultValue: 50, max: 1000)); + """).map { MetadataRow(kind: command.displayName, fields: $0) } + case .photosSharedLibrary: + guard try tableExists("ZSHARE") else { + throw LocalInventoryError.unsupportedSchema(store: database.path, detail: "missing photos_shared_library or ZSHARE tables") + } + let columns = try columns(in: "ZSHARE") + let titleExpression = coalesceRawExpression(candidates: ["ZTITLE", "ZSCOPEIDENTIFIER", "ZUUID"], columns: columns, fallback: "'Shared Library'") + let assetCountExpression = coalesceRawExpression(candidates: ["ZASSETCOUNT", "ZCLOUDITEMCOUNT", "ZPHOTOSCOUNT"], columns: columns, fallback: "0") + let statusExpression = columns.contains("ZSTATUS") ? "ZSTATUS" : "NULL" + let scopeExpression = columns.contains("ZSCOPETYPE") ? "ZSCOPETYPE" : "NULL" + let trashedPredicate = columns.contains("ZTRASHEDSTATE") ? "COALESCE(ZTRASHEDSTATE, 0) = 0" : "1 = 1" + return try query(""" + SELECT + \(titleExpression) AS title, + \(statusExpression) AS status, + \(scopeExpression) AS scopeType, + \(assetCountExpression) AS assetCount, + COALESCE(( + SELECT COUNT(*) + FROM ZSHAREPARTICIPANT p + WHERE p.ZSHARE = ZSHARE.Z_PK OR p.Z66_SHARE = ZSHARE.Z_PK + ), 0) AS participantCount + FROM ZSHARE + WHERE \(trashedPredicate) + ORDER BY title ASC + LIMIT \(bounded(options.limit, defaultValue: 50, max: 1000)); + """).map { MetadataRow(kind: command.displayName, fields: $0) } + default: + return [] + } + } + + private func reminderRows(for command: MetadataCommand, options: MetadataOptions) throws -> [MetadataRow] { + if try tableExists(command.tableName ?? "") { + guard let tableName = command.tableName else { return [] } + return try query("SELECT * FROM \(tableName)\(whereClause(for: command, options: options))\(orderClause(for: command)) LIMIT \(bounded(options.limit, defaultValue: 50, max: 1000));") + .map { MetadataRow(kind: command.displayName, fields: $0) } + .map { redact(row: $0, command: command, options: options) } + } + guard try tableExists("ZREMCDREMINDER"), try tableExists("ZREMCDBASELIST") else { + throw LocalInventoryError.unsupportedSchema(store: database.path, detail: "missing reminders metadata table or Apple Reminders CoreData tables") + } + let dueExpression = appleDateExpression("r.ZDUEDATE") + let createdExpression = appleDateExpression("r.ZCREATIONDATE") + let assignmentExpression = try tableExists("ZREMCDOBJECT") ? """ + EXISTS ( + SELECT 1 + FROM ZREMCDOBJECT o + WHERE (o.ZREMINDER = r.Z_PK OR o.ZREMINDER1 = r.Z_PK OR o.ZREMINDER2 = r.Z_PK OR o.Z_FOK_REMINDER = r.Z_PK) + AND o.ZASSIGNEE IS NOT NULL + ) + """ : "0" + var filters: [String?] = ["COALESCE(r.ZMARKEDFORDELETION, 0) = 0"] + switch command { + case .remindersFlagged: + filters.append("COALESCE(r.ZFLAGGED, 0) = 1") + filters.append("COALESCE(r.ZCOMPLETED, 0) = 0") + case .remindersToday: + filters.append("\(dueExpression) IS NOT NULL") + filters.append("\(dueExpression) <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+1 day')") + filters.append("COALESCE(r.ZCOMPLETED, 0) = 0") + case .remindersScheduled: + filters.append("\(dueExpression) IS NOT NULL") + filters.append(options.since.map { "\(dueExpression) >= '\(sqlEscape($0))'" }) + filters.append(options.until.map { "\(dueExpression) <= '\(sqlEscape($0))'" }) + case .remindersAssigned: + filters.append("\(assignmentExpression)") + filters.append("COALESCE(r.ZCOMPLETED, 0) = 0") + default: + break + } + return try query(""" + SELECT + COALESCE(NULLIF(r.ZTITLE, ''), 'Reminder ' || r.Z_PK) AS title, + COALESCE(NULLIF(l.ZNAME, ''), 'List ' || l.Z_PK) AS listName, + \(dueExpression) AS dueAt, + COALESCE(r.ZCOMPLETED, 0) AS isCompleted, + COALESCE(r.ZPRIORITY, 0) AS priority, + r.ZNOTES AS notes, + \(createdExpression) AS createdAt, + COALESCE(r.ZFLAGGED, 0) AS isFlagged, + CASE WHEN \(assignmentExpression) THEN 1 ELSE 0 END AS assignedToMe + FROM ZREMCDREMINDER r + LEFT JOIN ZREMCDBASELIST l ON l.Z_PK = r.ZLIST + \(andClause(filters)) + ORDER BY dueAt ASC, createdAt ASC + LIMIT \(bounded(options.limit, defaultValue: 50, max: 1000)); + """) + .map { MetadataRow(kind: command.displayName, fields: $0) } + .map { redact(row: $0, command: command, options: options) } + } + + private func homeRows(for command: MetadataCommand, options: MetadataOptions) throws -> [MetadataRow] { + if try tableExists(command.tableName ?? "") { + guard let tableName = command.tableName else { return [] } + return try query("SELECT * FROM \(tableName)\(whereClause(for: command, options: options))\(orderClause(for: command)) LIMIT \(bounded(options.limit, defaultValue: 50, max: 1000));") + .map { MetadataRow(kind: command.displayName, fields: $0) } + } + guard try tableExists("ZMKFHOME") else { + throw LocalInventoryError.unsupportedSchema(store: database.path, detail: "missing home metadata table or Apple HomeKit CoreData tables") + } + switch command { + case .homeHomes: + let accessoryCount = try tableExists("ZMKFACCESSORY") ? """ + (SELECT COUNT(*) + FROM ZMKFACCESSORY a + WHERE a.ZHOME = h.Z_PK) + """ : "0" + return try query(""" + SELECT + COALESCE(NULLIF(h.ZNAME, ''), 'Home ' || h.Z_PK) AS name, + COALESCE(h.ZOWNED, 0) AS primaryFlag, + \(accessoryCount) AS accessoryCount + FROM ZMKFHOME h + ORDER BY primaryFlag DESC, name ASC + LIMIT \(bounded(options.limit, defaultValue: 50, max: 1000)); + """).map { MetadataRow(kind: command.displayName, fields: $0) } + case .homeRooms: + guard try tableExists("ZMKFROOM") else { return [] } + let accessoryCount = try tableExists("ZMKFACCESSORY") ? """ + (SELECT COUNT(*) + FROM ZMKFACCESSORY a + WHERE a.ZROOM = r.Z_PK) + """ : "0" + let filters = andClause([ + options.home.map { "COALESCE(NULLIF(h.ZNAME, ''), 'Home ' || h.Z_PK) = '\(sqlEscape($0))'" }, + ]) + return try query(""" + SELECT + COALESCE(NULLIF(h.ZNAME, ''), 'Home ' || h.Z_PK) AS home, + COALESCE(NULLIF(r.ZNAME, ''), 'Room ' || r.Z_PK) AS name, + \(accessoryCount) AS accessoryCount + FROM ZMKFROOM r + LEFT JOIN ZMKFHOME h ON h.Z_PK = r.ZHOME + \(filters) + ORDER BY home ASC, name ASC + LIMIT \(bounded(options.limit, defaultValue: 50, max: 1000)); + """).map { MetadataRow(kind: command.displayName, fields: $0) } + case .homeAccessories: + guard try tableExists("ZMKFACCESSORY") else { return [] } + let filters = andClause([ + options.home.map { "COALESCE(NULLIF(h.ZNAME, ''), 'Home ' || h.Z_PK) = '\(sqlEscape($0))'" }, + options.room.map { "COALESCE(NULLIF(r.ZNAME, ''), 'Room ' || r.Z_PK) = '\(sqlEscape($0))'" }, + ]) + return try query(""" + SELECT + COALESCE(NULLIF(h.ZNAME, ''), 'Home ' || h.Z_PK) AS home, + COALESCE(NULLIF(r.ZNAME, ''), 'Room ' || r.Z_PK) AS room, + COALESCE(NULLIF(a.ZCONFIGUREDNAME, ''), NULLIF(a.ZPROVIDEDNAME, ''), 'Accessory ' || a.Z_PK) AS name, + a.ZMANUFACTURER AS manufacturer, + a.ZMODEL AS model, + CAST(a.ZACCESSORYCATEGORY AS TEXT) AS category, + CASE WHEN a.ZHOSTACCESSORY IS NULL THEN 0 ELSE 1 END AS bridged + FROM ZMKFACCESSORY a + LEFT JOIN ZMKFHOME h ON h.Z_PK = a.ZHOME + LEFT JOIN ZMKFROOM r ON r.Z_PK = a.ZROOM + \(filters) + ORDER BY home ASC, room ASC, name ASC + LIMIT \(bounded(options.limit, defaultValue: 50, max: 1000)); + """).map { MetadataRow(kind: command.displayName, fields: $0) } + case .homeScenes: + guard try tableExists("ZMKFACTIONSET") else { return [] } + let filters = andClause([ + options.home.map { "COALESCE(NULLIF(h.ZNAME, ''), 'Home ' || h.Z_PK) = '\(sqlEscape($0))'" }, + ]) + return try query(""" + SELECT + COALESCE(NULLIF(h.ZNAME, ''), 'Home ' || h.Z_PK) AS home, + COALESCE(NULLIF(s.ZNAME, ''), 'Scene ' || s.Z_PK) AS name, + 0 AS accessoryCount + FROM ZMKFACTIONSET s + LEFT JOIN ZMKFHOME h ON h.Z_PK = s.ZHOME + \(filters) + ORDER BY home ASC, name ASC + LIMIT \(bounded(options.limit, defaultValue: 50, max: 1000)); + """).map { MetadataRow(kind: command.displayName, fields: $0) } + default: + return [] + } + } + private func appleMailAccounts(options: MetadataOptions) throws -> [MetadataRow] { guard try tableExists("mailboxes") else { throw LocalInventoryError.unsupportedSchema(store: database.path, detail: "missing Apple Mail mailboxes table") @@ -248,17 +693,26 @@ public struct LocalMetadataStoreReader: Sendable { let home = FileManager.default.homeDirectoryForCurrentUser switch command { case .calendarAccounts, .calendarEvents, .calendarList: - return home.appendingPathComponent("Library/Calendars/Calendar Cache") + return firstExisting( + in: home.appendingPathComponent("Library/Group Containers/group.com.apple.calendar"), + matching: { $0.lastPathComponent == "Calendar.sqlitedb" } + ) ?? home.appendingPathComponent("Library/Calendars/Calendar Cache") case .findMyDevices, .findMyPeople: return home.appendingPathComponent("Library/Caches/com.apple.findmy.fmipcore/findmy.sqlite") case .mailAccounts, .mailMailboxes, .mailRecent: return home.appendingPathComponent("Library/Mail/V10/MailData/Envelope Index") case .booksCollections, .booksList: - return home.appendingPathComponent("Library/Containers/com.apple.iBooksX/Data/Documents/BKLibrary/BKLibrary.sqlite") + return firstExisting( + in: home.appendingPathComponent("Library/Containers/com.apple.iBooksX/Data/Documents/BKLibrary"), + matching: { $0.lastPathComponent.hasPrefix("BKLibrary-") && $0.pathExtension == "sqlite" } + ) ?? home.appendingPathComponent("Library/Containers/com.apple.iBooksX/Data/Documents/BKLibrary/BKLibrary.sqlite") case .healthSummary: return home.appendingPathComponent("Library/Health/healthdb_secure.sqlite") case .photosSharedAlbums, .photosSharedLibrary: - return home.appendingPathComponent("Pictures/Photos Library.photoslibrary/database/photos.sqlite") + return firstExisting( + in: home.appendingPathComponent("Pictures/Photos Library.photoslibrary/database"), + matching: { $0.lastPathComponent == "Photos.sqlite" } + ) ?? home.appendingPathComponent("Pictures/Photos Library.photoslibrary/database/Photos.sqlite") case .safariCloudTabsList: return home.appendingPathComponent("Library/Safari/CloudTabs.db") case .safariExtensionsList, .safariProfilesList: @@ -272,13 +726,16 @@ public struct LocalMetadataStoreReader: Sendable { case .freeformList: return home.appendingPathComponent("Library/Containers/com.apple.freeform/Data/Library/Application Support/freeform.sqlite") case .homeAccessories, .homeHomes, .homeRooms, .homeScenes: - return home.appendingPathComponent("Library/Application Support/com.apple.homed/Home.sqlite") + return firstExisting( + in: home.appendingPathComponent("Library/HomeKit"), + matching: { $0.lastPathComponent == "core.sqlite" } + ) ?? home.appendingPathComponent("Library/Application Support/com.apple.homed/Home.sqlite") case .voiceMemosList: return home.appendingPathComponent("Library/Application Support/com.apple.voicememos/Recordings.db") case .notesAccounts, .notesFolders, .notesShared, .notesTags: return home.appendingPathComponent("Library/Group Containers/group.com.apple.notes/NoteStore.sqlite") case .remindersAssigned, .remindersFlagged, .remindersScheduled, .remindersToday: - return home.appendingPathComponent("Library/Reminders/reminders.sqlite") + return AppleRemindersStoreResolver().database() ?? home.appendingPathComponent("Library/Reminders/reminders.sqlite") default: return home.appendingPathComponent("Library/Preferences/MobileMeAccounts.plist") } @@ -672,6 +1129,26 @@ public struct TaggedDriveItem: Codable, Equatable, Sendable { public let iCloudStatus: ICloudFileStatus } +public struct FinderTagsStoreResolver: Sendable { + public let syncedFile: URL + public let preferencesFile: URL + + public init( + syncedFile: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/SyncedPreferences/com.apple.finder.plist"), + preferencesFile: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Preferences/com.apple.finder.plist") + ) { + self.syncedFile = syncedFile + self.preferencesFile = preferencesFile + } + + public func resolvedPreferencesFile() -> URL { + if FileManager.default.fileExists(atPath: syncedFile.path) { + return syncedFile + } + return preferencesFile + } +} + public struct FinderTagsReader: Sendable { public let preferencesFile: URL public let driveRoot: URL @@ -695,7 +1172,8 @@ public struct FinderTagsReader: Sendable { } public func items(tag: String, path: String?, limit: Int) throws -> [TaggedDriveItem] { - let files = try ICloudDriveInventoryReader(rootDirectory: driveRoot).listFiles(path: path, depth: Int.max) + let scanLimit = max(200, bounded(limit, defaultValue: 50, max: 1_000) * 5) + let files = try ICloudDriveInventoryReader(rootDirectory: driveRoot).listFiles(path: path, depth: Int.max, limit: scanLimit) return files .filter { $0.name.localizedCaseInsensitiveContains(tag) || $0.path.localizedCaseInsensitiveContains(".\(tag).") } .prefix(max(1, limit)) @@ -708,6 +1186,15 @@ private func bounded(_ value: Int, defaultValue: Int, max: Int) -> Int { return Swift.min(value, max) } +private func andClause(_ filters: [String?]) -> String { + let active = filters.compactMap { $0 }.filter { !$0.isEmpty } + return active.isEmpty ? "" : " WHERE " + active.joined(separator: " AND ") +} + +private func appleDateExpression(_ column: String) -> String { + "CASE WHEN \(column) IS NULL THEN NULL ELSE strftime('%Y-%m-%dT%H:%M:%SZ', \(column) + 978307200, 'unixepoch') END" +} + private func sqlEscape(_ value: String) -> String { value.replacingOccurrences(of: "'", with: "''") } @@ -722,6 +1209,20 @@ private func coalesceRawExpression(candidates: [String], columns: Set, t return "COALESCE(\((available + [fallback]).joined(separator: ", ")))" } +private func firstExisting(in directory: URL, matching predicate: (URL) -> Bool) -> URL? { + guard let contents = try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { + return nil + } + return contents + .filter(predicate) + .sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending } + .first +} + private func redactURL(_ raw: String) -> String { guard let components = URLComponents(string: raw), let scheme = components.scheme, let host = components.host else { return raw diff --git a/Sources/ICloudCLICore/CommandLine.swift b/Sources/ICloudCLICore/CommandLine.swift index 4ec19a4..702a7f6 100644 --- a/Sources/ICloudCLICore/CommandLine.swift +++ b/Sources/ICloudCLICore/CommandLine.swift @@ -164,10 +164,12 @@ public struct PhotosScreenshotsOptions: Equatable, Sendable { public struct PhotosListOptions: Equatable, Sendable { public var format: OutputFormat public var photosLibrary: URL + public var limit: Int - public init(format: OutputFormat = .json, photosLibrary: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Pictures/Photos Library.photoslibrary")) { + public init(format: OutputFormat = .json, photosLibrary: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Pictures/Photos Library.photoslibrary"), limit: Int = 200) { self.format = format self.photosLibrary = photosLibrary + self.limit = limit } } @@ -195,7 +197,7 @@ public struct RemindersListOptions: Equatable, Sendable { public var dueAfter: String? public var includeCompleted: Bool - public init(format: OutputFormat = .json, store: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Reminders/reminders.sqlite"), list: String? = nil, dueBefore: String? = nil, dueAfter: String? = nil, includeCompleted: Bool = false) { + public init(format: OutputFormat = .json, store: URL = AppleRemindersStoreResolver().database() ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Reminders/reminders.sqlite"), list: String? = nil, dueBefore: String? = nil, dueAfter: String? = nil, includeCompleted: Bool = false) { self.format = format self.store = store self.list = list @@ -266,7 +268,7 @@ public struct MapsOptions: Equatable, Sendable { public var store: URL public var limit: Int - public init(format: OutputFormat = .json, store: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Containers/com.apple.Maps/Data/Library/Maps/Maps.sqlite"), limit: Int = 20) { + public init(format: OutputFormat = .json, store: URL = AppleMapsStoreResolver().database() ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Containers/com.apple.Maps/Data/Library/Maps/Maps.sqlite"), limit: Int = 20) { self.format = format self.store = store self.limit = limit @@ -433,6 +435,7 @@ public struct MetadataOptions: Equatable, Sendable { public var since: String? public var until: String? public var limit: Int + public var driveStatusLimit: Int? public var confirmSensitive: Bool public var includeAttendees: Bool public var includeCoordinates: Bool @@ -465,6 +468,7 @@ public struct MetadataOptions: Equatable, Sendable { since: String? = nil, until: String? = nil, limit: Int = 50, + driveStatusLimit: Int? = nil, confirmSensitive: Bool = false, includeAttendees: Bool = false, includeCoordinates: Bool = false, @@ -496,6 +500,7 @@ public struct MetadataOptions: Equatable, Sendable { self.since = since self.until = until self.limit = limit + self.driveStatusLimit = driveStatusLimit self.confirmSensitive = confirmSensitive self.includeAttendees = includeAttendees self.includeCoordinates = includeCoordinates @@ -1018,7 +1023,10 @@ public struct CLIParser: Sendable { switch token { case "--format": options.format = try parseFormat(after: token, in: tokens, at: &index) case "--handoff-dir": options.handoffDirectory = try parseURL(after: token, in: tokens, at: &index) - case "--limit": options.limit = Int(try value(after: token, in: tokens, at: &index)) ?? options.limit + case "--limit": + let rawLimit = try value(after: token, in: tokens, at: &index) + guard let limit = Int(rawLimit) else { throw CLIParseError.missingValue(token) } + options.limit = limit default: throw CLIParseError.unknownCommand(token) } index += 1 @@ -1061,6 +1069,7 @@ public struct CLIParser: Sendable { switch token { case "--format": options.format = try parseFormat(after: token, in: tokens, at: &index) case "--photos-library": options.photosLibrary = try parseURL(after: token, in: tokens, at: &index) + case "--limit": options.limit = Int(try value(after: token, in: tokens, at: &index)) ?? options.limit default: throw CLIParseError.unknownCommand(token) } index += 1 @@ -1239,7 +1248,7 @@ public struct CLIParser: Sendable { let token = tokens[index] switch token { case "--format": options.format = try parseFormat(after: token, in: tokens, at: &index) - case "--store", "--metadata-store", "--calendar-store", "--findmy-store", "--mail-store", "--books-store", "--health-store", "--notes-store", "--photos-store", "--safari-store", "--music-store", "--weather-store", "--stocks-store", "--freeform-store", "--home-store", "--voice-memos-store": + case "--store", "--metadata-store", "--calendar-store", "--findmy-store", "--mail-store", "--books-store", "--health-store", "--notes-store", "--photos-store", "--reminders-store", "--safari-store", "--music-store", "--weather-store", "--stocks-store", "--freeform-store", "--home-store", "--voice-memos-store": options.store = try parseURL(after: token, in: tokens, at: &index) case "--cache-file": options.store = try parseURL(after: token, in: tokens, at: &index) @@ -1270,7 +1279,10 @@ public struct CLIParser: Sendable { case "--tag": options.tag = try value(after: token, in: tokens, at: &index) case "--since": options.since = try value(after: token, in: tokens, at: &index) case "--until": options.until = try value(after: token, in: tokens, at: &index) - case "--limit": options.limit = Int(try value(after: token, in: tokens, at: &index)) ?? options.limit + case "--limit": + let limit = Int(try value(after: token, in: tokens, at: &index)) ?? options.limit + options.limit = limit + options.driveStatusLimit = limit case "--confirm-sensitive": options.confirmSensitive = true case "--include-attendees": options.includeAttendees = true case "--include-coordinates": options.includeCoordinates = true @@ -1325,13 +1337,13 @@ Usage: icloud-cli handoff list [--limit N] [--format json|text] [--handoff-dir PATH] icloud-cli drive list [--path PATH] [--depth N] [--show-status] [--format json|text] [--icloud-root PATH] icloud-cli drive containers [--sort-by size|modified|name] [--format json|text] [--icloud-root PATH] - icloud-cli drive status [--path PATH] [--format json|text] [--icloud-root PATH] - icloud-cli drive errors [--path PATH] [--format json|text] [--icloud-root PATH] - icloud-cli drive shared [--path PATH] [--format json|text] [--icloud-root PATH] + icloud-cli drive status [--path PATH] [--limit N] [--format json|text] [--icloud-root PATH] + icloud-cli drive errors [--path PATH] [--limit N] [--format json|text] [--icloud-root PATH] + icloud-cli drive shared [--path PATH] [--limit N] [--format json|text] [--icloud-root PATH] icloud-cli drive recents [--since ISO8601] [--limit N] [--format json|text] [--icloud-root PATH] icloud-cli shortcuts list [--name PATTERN] [--format json|text] [--shortcuts-dir PATH] icloud-cli photos screenshots [--format json|text] [--screenshots-dir PATH] - icloud-cli photos list [--format json|text] [--photos-library PATH] + icloud-cli photos list [--limit N] [--format json|text] [--photos-library PATH] icloud-cli photos shared-albums [--format json|text] [--photos-store PATH] icloud-cli photos shared-library [--format json|text] [--photos-store PATH] icloud-cli notes list [--folder NAME] [--modified-since ISO8601] [--include-body] [--format json|text] [--notes-store PATH] diff --git a/Sources/ICloudCLICore/CommandRunner.swift b/Sources/ICloudCLICore/CommandRunner.swift index 50cc171..07f4f85 100644 --- a/Sources/ICloudCLICore/CommandRunner.swift +++ b/Sources/ICloudCLICore/CommandRunner.swift @@ -95,7 +95,7 @@ public struct CommandRunner: Sendable { output(try render(notes, format: options.format)) return 0 case .photosList(let options): - let photos = try PhotosInventoryReader(photosLibraryDirectory: options.photosLibrary).listPhotos() + let photos = try PhotosInventoryReader(photosLibraryDirectory: options.photosLibrary).listPhotos(limit: options.limit) output(try render(photos, format: options.format)) return 0 case .photosScreenshots(let options): @@ -198,22 +198,22 @@ public struct CommandRunner: Sendable { return rendered case .driveStatus: let reader = ICloudDriveInventoryReader(rootDirectory: options.rootDirectory ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Mobile Documents")) - return try render(try reader.syncStatus(path: options.path), format: options.format) + return try render(try reader.syncStatus(path: options.path, limit: options.driveStatusLimit), format: options.format) case .driveErrors: let reader = ICloudDriveInventoryReader(rootDirectory: options.rootDirectory ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Mobile Documents")) - return try render(try reader.errorFiles(path: options.path), format: options.format) + return try render(try reader.errorFiles(path: options.path, limit: options.limit), format: options.format) case .driveShared: let reader = ICloudDriveInventoryReader(rootDirectory: options.rootDirectory ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Mobile Documents")) - return try render(try reader.sharedItems(path: options.path), format: options.format) + return try render(try reader.sharedItems(path: options.path, limit: options.limit), format: options.format) case .driveRecents: let reader = ICloudDriveInventoryReader(rootDirectory: options.rootDirectory ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Mobile Documents")) return try render(try reader.recentFiles(since: options.since, limit: options.limit), format: options.format) case .tagsList: - let reader = FinderTagsReader(preferencesFile: options.store ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/SyncedPreferences/com.apple.finder.plist"), driveRoot: options.rootDirectory ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Mobile Documents")) + let reader = FinderTagsReader(preferencesFile: options.store ?? FinderTagsStoreResolver().resolvedPreferencesFile(), driveRoot: options.rootDirectory ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Mobile Documents")) return try render(try reader.listTags(), format: options.format) case .taggedItems: guard let tag = options.tag else { throw CLIParseError.missingValue("--tag") } - let reader = FinderTagsReader(preferencesFile: options.store ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/SyncedPreferences/com.apple.finder.plist"), driveRoot: options.rootDirectory ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Mobile Documents")) + let reader = FinderTagsReader(preferencesFile: options.store ?? FinderTagsStoreResolver().resolvedPreferencesFile(), driveRoot: options.rootDirectory ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Mobile Documents")) return try render(try reader.items(tag: tag, path: options.path, limit: options.limit), format: options.format) default: let store = options.store ?? LocalMetadataStoreReader.defaultStore(for: command) diff --git a/Sources/ICloudCLICore/DriveInventory.swift b/Sources/ICloudCLICore/DriveInventory.swift index 3a57a12..1028241 100644 --- a/Sources/ICloudCLICore/DriveInventory.swift +++ b/Sources/ICloudCLICore/DriveInventory.swift @@ -74,7 +74,7 @@ public struct ICloudDriveInventoryReader: Sendable { self.rootDirectory = rootDirectory.standardizedFileURL } - public func listFiles(path requestedPath: String? = nil, depth: Int = 2) throws -> [ICloudDriveFile] { + public func listFiles(path requestedPath: String? = nil, depth: Int = 2, limit: Int? = nil) throws -> [ICloudDriveFile] { guard FileManager.default.fileExists(atPath: rootDirectory.path) else { throw DriveInventoryError.missingRoot(rootDirectory.path) } let startURL = try scopedURL(for: requestedPath) let startValues = try? startURL.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey]) @@ -82,13 +82,14 @@ public struct ICloudDriveInventoryReader: Sendable { return [fileEntry(for: startURL, values: startValues)] } let maxDepth = max(0, depth) + let maxFiles = limit.map { max(1, $0) } var result: [ICloudDriveFile] = [] - try walkFiles(at: startURL, currentDepth: 0, maxDepth: maxDepth, into: &result) + try walkFiles(at: startURL, currentDepth: 0, maxDepth: maxDepth, maxFiles: maxFiles, into: &result) return result.sorted { lhs, rhs in lhs.path.localizedStandardCompare(rhs.path) == .orderedAscending } } - public func syncStatus(path requestedPath: String? = nil) throws -> ICloudDriveSyncSummary { - let files = try listFiles(path: requestedPath, depth: Int.max) + public func syncStatus(path requestedPath: String? = nil, limit: Int? = nil) throws -> ICloudDriveSyncSummary { + let files = try listFiles(path: requestedPath, depth: Int.max, limit: limit) return ICloudDriveSyncSummary( downloadedCount: files.filter { $0.iCloudStatus == .downloaded }.count, cloudOnlyCount: files.filter { $0.iCloudStatus == .evicted || $0.iCloudStatus == .notDownloaded }.count, @@ -100,15 +101,19 @@ public struct ICloudDriveInventoryReader: Sendable { ) } - public func errorFiles(path requestedPath: String? = nil) throws -> [ICloudDriveErrorEntry] { - try listFiles(path: requestedPath, depth: Int.max) + public func errorFiles(path requestedPath: String? = nil, limit: Int = 500, scanLimit: Int? = nil) throws -> [ICloudDriveErrorEntry] { + let resultLimit = max(1, limit) + let traversalLimit = scanLimit ?? filteredDriveScanLimit(for: resultLimit) + return try listFiles(path: requestedPath, depth: Int.max, limit: traversalLimit) .filter { $0.iCloudStatus == .error } + .prefix(resultLimit) .map { ICloudDriveErrorEntry(path: $0.path, category: "icloud-sync-error") } } public func recentFiles(since: String? = nil, limit: Int = 50) throws -> [ICloudDriveFile] { let floor = since.flatMap { ISO8601DateFormatter().date(from: $0) } - return try listFiles(depth: Int.max) + let scanLimit = max(200, bounded(limit, defaultValue: 50, max: 1_000) * 5) + return try listFiles(depth: Int.max, limit: scanLimit) .filter { file in guard let floor else { return true } return file.modifiedAt.map { $0 >= floor } ?? false @@ -118,9 +123,12 @@ public struct ICloudDriveInventoryReader: Sendable { .map { $0 } } - public func sharedItems(path requestedPath: String? = nil) throws -> [ICloudDriveSharedItem] { - try listFiles(path: requestedPath, depth: Int.max) + public func sharedItems(path requestedPath: String? = nil, limit: Int = 500, scanLimit: Int? = nil) throws -> [ICloudDriveSharedItem] { + let resultLimit = max(1, limit) + let traversalLimit = scanLimit ?? filteredDriveScanLimit(for: resultLimit) + return try listFiles(path: requestedPath, depth: Int.max, limit: traversalLimit) .filter { $0.path.localizedCaseInsensitiveContains(".shared") } + .prefix(resultLimit) .map { file in ICloudDriveSharedItem(path: file.path, owner: nil, role: nil, dateShared: file.modifiedAt, iCloudStatus: file.iCloudStatus) } @@ -133,7 +141,7 @@ public struct ICloudDriveInventoryReader: Sendable { for child in children { let values = try? child.resourceValues(forKeys: [.isDirectoryKey]) guard values?.isDirectory == true else { continue } - let stats = directoryStats(child) + let stats: (sizeBytes: Int64?, modifiedAt: Date?) = shouldComputeContainerStats(for: sortBy) ? directoryStats(child) : (nil, nil) containers.append(ICloudDriveContainer(bundleId: child.lastPathComponent, displayName: displayName(for: child.lastPathComponent), sizeBytes: stats.sizeBytes, modifiedAt: stats.modifiedAt)) } return sort(containers, by: sortBy) @@ -149,7 +157,14 @@ public struct ICloudDriveInventoryReader: Sendable { return standardized } - private func walkFiles(at directory: URL, currentDepth: Int, maxDepth: Int, into result: inout [ICloudDriveFile]) throws { + private func filteredDriveScanLimit(for resultLimit: Int) -> Int { + max(200, bounded(resultLimit, defaultValue: 500, max: 2_000) * 5) + } + + private func walkFiles(at directory: URL, currentDepth: Int, maxDepth: Int, maxFiles: Int?, into result: inout [ICloudDriveFile]) throws { + if let maxFiles, result.count >= maxFiles { + return + } let directoryValues = try? directory.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey]) if directoryValues?.isDirectory != true { result.append(fileEntry(for: directory, values: directoryValues)) @@ -157,9 +172,12 @@ public struct ICloudDriveInventoryReader: Sendable { } let children = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey], options: []) for child in children { + if let maxFiles, result.count >= maxFiles { + return + } let values = try? child.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey]) if values?.isDirectory == true { - if currentDepth < maxDepth { try walkFiles(at: child, currentDepth: currentDepth + 1, maxDepth: maxDepth, into: &result) } + if currentDepth < maxDepth { try walkFiles(at: child, currentDepth: currentDepth + 1, maxDepth: maxDepth, maxFiles: maxFiles, into: &result) } continue } result.append(fileEntry(for: child, values: values)) @@ -173,6 +191,7 @@ public struct ICloudDriveInventoryReader: Sendable { private func status(for url: URL) -> ICloudFileStatus { let name = url.lastPathComponent + if name.hasPrefix(".broken") && name.hasSuffix(".icloud") { return .error } if name.hasPrefix(".") && name.hasSuffix(".icloud") { return .evicted } if name.hasSuffix(".icloud") { return .uploading } return .downloaded @@ -218,6 +237,13 @@ public struct ICloudDriveInventoryReader: Sendable { bundleId.replacingOccurrences(of: "com~apple~", with: "Apple ").replacingOccurrences(of: "~", with: ".") } + private func shouldComputeContainerStats(for sortBy: DriveSortKey) -> Bool { + switch sortBy { + case .size, .modified: return true + case .name: return false + } + } + private func sort(_ containers: [ICloudDriveContainer], by key: DriveSortKey) -> [ICloudDriveContainer] { containers.sorted { lhs, rhs in switch key { @@ -228,3 +254,8 @@ public struct ICloudDriveInventoryReader: Sendable { } } } + +private func bounded(_ value: Int, defaultValue: Int, max: Int) -> Int { + guard value > 0 else { return defaultValue } + return Swift.min(value, max) +} diff --git a/Sources/ICloudCLICore/LocalInventories.swift b/Sources/ICloudCLICore/LocalInventories.swift index 9a4d9d9..ccb41c9 100644 --- a/Sources/ICloudCLICore/LocalInventories.swift +++ b/Sources/ICloudCLICore/LocalInventories.swift @@ -3,6 +3,7 @@ import Foundation public enum LocalInventoryError: Error, LocalizedError, Equatable { case missingRoot(String) case missingStore(String) + case permissionDenied(String) case sensitiveConfirmationRequired(String) case unsupportedSchema(store: String, detail: String) case sqliteFailure(String) @@ -11,6 +12,8 @@ public enum LocalInventoryError: Error, LocalizedError, Equatable { switch self { case .missingRoot(let path): return "Inventory root not available: \(path)" case .missingStore(let path): return "Inventory store not available: \(path)" + case .permissionDenied(let path): + return "Permission denied reading local inventory store: \(path). Grant Full Disk Access to the calling terminal or agent process, then retry." case .sensitiveConfirmationRequired(let command): return "\(command) reads high-sensitivity local data; rerun with --confirm-sensitive" case .unsupportedSchema(let store, let detail): @@ -64,7 +67,7 @@ public struct PhotosInventoryReader: Sendable { .sorted { $0.filename.localizedStandardCompare($1.filename) == .orderedAscending } } - public func listPhotos() throws -> [PhotoAsset] { + public func listPhotos(limit: Int = 200) throws -> [PhotoAsset] { guard FileManager.default.fileExists(atPath: photosLibraryDirectory.path) else { throw LocalInventoryError.missingRoot(photosLibraryDirectory.path) } @@ -75,6 +78,7 @@ public struct PhotosInventoryReader: Sendable { ) else { return [] } + let maxAssets = bounded(limit, defaultValue: 200, max: 10_000) var assets: [PhotoAsset] = [] for case let url as URL in enumerator { let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .creationDateKey, .contentModificationDateKey]) @@ -88,6 +92,9 @@ public struct PhotosInventoryReader: Sendable { isFavorite: false, albumNames: [] )) + if assets.count >= maxAssets { + break + } } return assets.sorted { $0.filename.localizedStandardCompare($1.filename) == .orderedAscending } } @@ -303,10 +310,16 @@ public struct LocalSQLiteInventoryReader: Sendable { } public func reminderLists() throws -> [ReminderListSummary] { - try query("SELECT listName AS name, COUNT(*) AS itemCount FROM reminders GROUP BY listName ORDER BY listName ASC;") + if try tableExists("ZREMCDREMINDER"), try tableExists("ZREMCDBASELIST") { + return try appleReminderLists() + } + return try query("SELECT listName AS name, COUNT(*) AS itemCount FROM reminders GROUP BY listName ORDER BY listName ASC;") } public func reminders(list: String?, dueBefore: String?, dueAfter: String?, includeCompleted: Bool) throws -> [ReminderEntry] { + if try tableExists("ZREMCDREMINDER"), try tableExists("ZREMCDBASELIST") { + return try appleReminders(list: list, dueBefore: dueBefore, dueAfter: dueAfter, includeCompleted: includeCompleted) + } var filters: [String?] = [ list.map { "listName = '\(sqlEscape($0))'" }, dueBefore.map { "dueAt IS NOT NULL AND dueAt <= '\(sqlEscape($0))'" }, @@ -317,8 +330,54 @@ public struct LocalSQLiteInventoryReader: Sendable { return try query("SELECT title, listName, dueAt, isCompleted, priority, notes, createdAt FROM reminders\(whereClause) ORDER BY dueAt ASC, createdAt ASC;") } + private func appleReminderLists() throws -> [ReminderListSummary] { + try query(""" + SELECT + COALESCE(NULLIF(l.ZNAME, ''), 'List ' || l.Z_PK) AS name, + COUNT(r.Z_PK) AS itemCount + FROM ZREMCDBASELIST l + LEFT JOIN ZREMCDREMINDER r ON r.ZLIST = l.Z_PK + AND COALESCE(r.ZMARKEDFORDELETION, 0) = 0 + WHERE COALESCE(l.ZMARKEDFORDELETION, 0) = 0 + GROUP BY l.Z_PK + ORDER BY name ASC; + """) + } + + private func appleReminders(list: String?, dueBefore: String?, dueAfter: String?, includeCompleted: Bool) throws -> [ReminderEntry] { + let dueExpression = appleDateExpression("r.ZDUEDATE") + let createdExpression = appleDateExpression("r.ZCREATIONDATE") + var filters: [String?] = [ + "COALESCE(r.ZMARKEDFORDELETION, 0) = 0", + list.map { "COALESCE(NULLIF(l.ZNAME, ''), 'List ' || l.Z_PK) = '\(sqlEscape($0))'" }, + dueBefore.map { "\(dueExpression) IS NOT NULL AND \(dueExpression) <= '\(sqlEscape($0))'" }, + dueAfter.map { "\(dueExpression) IS NOT NULL AND \(dueExpression) >= '\(sqlEscape($0))'" }, + ] + if !includeCompleted { + filters.append("COALESCE(r.ZCOMPLETED, 0) = 0") + } + let whereClause = andClause(filters) + return try query(""" + SELECT + COALESCE(NULLIF(r.ZTITLE, ''), 'Reminder ' || r.Z_PK) AS title, + COALESCE(NULLIF(l.ZNAME, ''), 'List ' || l.Z_PK) AS listName, + \(dueExpression) AS dueAt, + COALESCE(r.ZCOMPLETED, 0) AS isCompleted, + COALESCE(r.ZPRIORITY, 0) AS priority, + r.ZNOTES AS notes, + \(createdExpression) AS createdAt + FROM ZREMCDREMINDER r + LEFT JOIN ZREMCDBASELIST l ON l.Z_PK = r.ZLIST + \(whereClause) + ORDER BY dueAt ASC, createdAt ASC; + """) + } + public func safariHistory(confirmSensitive: Bool, since: String?, until: String?, limit: Int, redactURLs: Bool) throws -> [SafariHistoryEntry] { guard confirmSensitive else { throw LocalInventoryError.sensitiveConfirmationRequired("icloud-cli safari history") } + if try tableExists("history_items"), try tableExists("history_visits") { + return try appleSafariHistory(since: since, until: until, limit: limit, redactURLs: redactURLs) + } let floor = since ?? ISO8601DateFormatter().string(from: Date(timeIntervalSinceNow: -86_400)) let whereClause = andClause([ "visitedAt >= '\(sqlEscape(floor))'", @@ -329,6 +388,31 @@ public struct LocalSQLiteInventoryReader: Sendable { return rows.map { SafariHistoryEntry(url: redactURL($0.url), title: $0.title, visitedAt: $0.visitedAt, visitCount: $0.visitCount) } } + private func appleSafariHistory(since: String?, until: String?, limit: Int, redactURLs: Bool) throws -> [SafariHistoryEntry] { + let timestampExpression = "strftime('%Y-%m-%dT%H:%M:%SZ', v.visit_time + 978307200, 'unixepoch')" + let floor = since ?? ISO8601DateFormatter().string(from: Date(timeIntervalSinceNow: -86_400)) + let whereClause = andClause([ + "i.url IS NOT NULL", + "v.load_successful = 1", + "\(timestampExpression) >= '\(sqlEscape(floor))'", + until.map { "\(timestampExpression) <= '\(sqlEscape($0))'" }, + ]) + let rows: [SafariHistoryEntry] = try query(""" + SELECT + i.url AS url, + v.title AS title, + \(timestampExpression) AS visitedAt, + COALESCE(i.visit_count, 1) AS visitCount + FROM history_visits v + JOIN history_items i ON i.id = v.history_item + \(whereClause) + ORDER BY v.visit_time DESC + LIMIT \(bounded(limit, defaultValue: 100, max: 1000)); + """) + if !redactURLs { return rows } + return rows.map { SafariHistoryEntry(url: redactURL($0.url), title: $0.title, visitedAt: $0.visitedAt, visitCount: $0.visitCount) } + } + public func messageConversations(limit: Int = 50) throws -> [MessageConversation] { if try tableExists("message_conversations") { return try query("SELECT chatIdentifier, displayName, participantCount, lastMessageAt, messageCount FROM message_conversations ORDER BY lastMessageAt DESC LIMIT \(bounded(limit, defaultValue: 50, max: 1000));") @@ -368,10 +452,10 @@ public struct LocalSQLiteInventoryReader: Sendable { let floorPredicate = appleFloor.map { "WHERE m.date >= \($0)" } ?? "" return try query(""" SELECT - COALESCE(c.chat_identifier, c.guid) AS chatIdentifier, + COALESCE(c.chat_identifier, c.guid, h.id, 'unknown') AS chatIdentifier, h.id AS sender, CAST(m.date AS TEXT) AS sentAt, - m.is_from_me AS isFromMe, + COALESCE(m.is_from_me, 0) AS isFromMe, \(appleBodyColumn) FROM message m LEFT JOIN handle h ON h.ROWID = m.handle_id @@ -470,15 +554,54 @@ public struct LocalSQLiteInventoryReader: Sendable { } public func mapFavorites() throws -> [MapPlace] { + if try tableExists("ZFAVORITEITEM") { + return try appleMapFavorites() + } let rows: [RawMapPlace] = try query("SELECT name, address, latitude, longitude, category, NULL AS searchedAt FROM map_favorites ORDER BY name ASC;") return rows.map { $0.place() } } public func mapRecents(limit: Int) throws -> [MapPlace] { + if try tableExists("ZHISTORYITEM") { + return try appleMapRecents(limit: limit) + } let rows: [RawMapPlace] = try query("SELECT name, address, latitude, longitude, category, searchedAt FROM map_recents ORDER BY searchedAt DESC LIMIT \(bounded(limit, defaultValue: 20, max: 1000));") return rows.map { $0.place() } } + private func appleMapFavorites() throws -> [MapPlace] { + let rows: [RawMapPlace] = try query(""" + SELECT + COALESCE(NULLIF(ZCUSTOMNAME, ''), NULLIF(ZMAPITEMNAME, ''), NULLIF(ZMAPITEMADDRESS, ''), 'Favorite ' || Z_PK) AS name, + ZMAPITEMADDRESS AS address, + ZLATITUDE AS latitude, + ZLONGITUDE AS longitude, + ZMAPITEMCATEGORY AS category, + NULL AS searchedAt + FROM ZFAVORITEITEM + WHERE COALESCE(ZHIDDEN, 0) = 0 + ORDER BY COALESCE(ZPOSITIONINDEX, Z_PK) ASC; + """) + return rows.map { $0.place() } + } + + private func appleMapRecents(limit: Int) throws -> [MapPlace] { + let searchedAt = appleDateExpression("ZMODIFICATIONTIME") + let rows: [RawMapPlace] = try query(""" + SELECT + COALESCE(NULLIF(ZCUSTOMNAME, ''), NULLIF(ZLOCATIONDISPLAY, ''), NULLIF(ZQUERY, ''), 'Recent Place ' || Z_PK) AS name, + ZLOCATIONDISPLAY AS address, + ZLATITUDE AS latitude, + ZLONGITUDE AS longitude, + NULL AS category, + \(searchedAt) AS searchedAt + FROM ZHISTORYITEM + ORDER BY ZMODIFICATIONTIME DESC + LIMIT \(bounded(limit, defaultValue: 20, max: 1000)); + """) + return rows.map { $0.place() } + } + public func newsHistory(since: String?, limit: Int) throws -> [NewsHistoryEntry] { let floor = since ?? ISO8601DateFormatter().string(from: Date(timeIntervalSinceNow: -604_800)) return try query("SELECT title, source, url, readAt, topic FROM news_history WHERE readAt >= '\(sqlEscape(floor))' ORDER BY readAt DESC LIMIT \(bounded(limit, defaultValue: 50, max: 1000));") @@ -599,6 +722,58 @@ public struct AddressBookStoreResolver: Sendable { } } +public struct AppleRemindersStoreResolver: Sendable { + public let storesDirectory: URL + + public init(storesDirectory: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Group Containers/group.com.apple.reminders/Container_v1/Stores")) { + self.storesDirectory = storesDirectory + } + + public func database() -> URL? { + guard let contents = try? FileManager.default.contentsOfDirectory( + at: storesDirectory, + includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey], + options: [.skipsHiddenFiles] + ) else { + return nil + } + return contents + .filter { $0.lastPathComponent.hasPrefix("Data-") && $0.pathExtension == "sqlite" } + .sorted { lhs, rhs in + let lhsValues = try? lhs.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey]) + let rhsValues = try? rhs.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey]) + let lhsDate = lhsValues?.contentModificationDate ?? .distantPast + let rhsDate = rhsValues?.contentModificationDate ?? .distantPast + if lhsDate != rhsDate { + return lhsDate > rhsDate + } + let lhsSize = lhsValues?.fileSize ?? 0 + let rhsSize = rhsValues?.fileSize ?? 0 + if lhsSize != rhsSize { + return lhsSize > rhsSize + } + return lhs.lastPathComponent.localizedStandardCompare(rhs.lastPathComponent) == .orderedAscending + } + .first + } +} + +public struct AppleMapsStoreResolver: Sendable { + public let mapsDirectory: URL + + public init(mapsDirectory: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Containers/com.apple.Maps/Data/Maps")) { + self.mapsDirectory = mapsDirectory + } + + public func database() -> URL? { + let candidates = [ + mapsDirectory.appendingPathComponent("MapsSync_0.0.1"), + mapsDirectory.appendingPathComponent("MapsSync_0.0.1_deviceLocalCache.db"), + ] + return candidates.first { FileManager.default.fileExists(atPath: $0.path) } + } +} + private struct SQLiteTableRow: Decodable { let name: String } @@ -615,6 +790,9 @@ private struct ContactFieldListSQL { func sqliteError(from errorData: Data, store: String) -> LocalInventoryError { let message = String(decoding: errorData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) let lowercased = message.lowercased() + if lowercased.contains("authorization denied") || lowercased.contains("operation not permitted") || lowercased.contains("permission denied") { + return .permissionDenied(store) + } if lowercased.contains("no such table") || lowercased.contains("no such column") { return .unsupportedSchema(store: store, detail: message) } diff --git a/Sources/ICloudCLICore/SafariBookmarks.swift b/Sources/ICloudCLICore/SafariBookmarks.swift index 97ee6fd..05043aa 100644 --- a/Sources/ICloudCLICore/SafariBookmarks.swift +++ b/Sources/ICloudCLICore/SafariBookmarks.swift @@ -17,11 +17,12 @@ public struct SafariReadingListItem: Codable, Equatable, Sendable { } public enum SafariBookmarksError: Error, LocalizedError, Equatable { - case missingBookmarks(String), unreadableBookmarks(String), noBookmarksFound(String), noReadingListItemsFound(String) + case missingBookmarks(String), unreadableBookmarks(String), permissionDenied(String), noBookmarksFound(String), noReadingListItemsFound(String) public var errorDescription: String? { switch self { case .missingBookmarks(let path): return "No Safari bookmarks file found: \(path)" case .unreadableBookmarks(let path): return "Could not read Safari bookmarks file: \(path)" + case .permissionDenied(let path): return "Permission denied reading Safari bookmarks file: \(path). Grant Full Disk Access to the calling terminal or agent process, then retry." case .noBookmarksFound(let path): return "Safari bookmarks file did not contain bookmark URLs: \(path)" case .noReadingListItemsFound(let path): return "Safari bookmarks file did not contain reading list URLs: \(path)" } @@ -46,8 +47,24 @@ public struct SafariBookmarksReader: Sendable { private var bookmarksPath: URL { safariDirectory.appendingPathComponent("Bookmarks.plist") } private func readBookmarksPlist() throws -> Any { guard FileManager.default.fileExists(atPath: bookmarksPath.path) else { throw SafariBookmarksError.missingBookmarks(bookmarksPath.path) } - do { return try PropertyListSerialization.propertyList(from: Data(contentsOf: bookmarksPath), options: [], format: nil) } catch { throw SafariBookmarksError.unreadableBookmarks(bookmarksPath.path) } + do { + return try PropertyListSerialization.propertyList(from: Data(contentsOf: bookmarksPath), options: [], format: nil) + } catch { + if isPermissionDenied(error) { + throw SafariBookmarksError.permissionDenied(bookmarksPath.path) + } + throw SafariBookmarksError.unreadableBookmarks(bookmarksPath.path) + } + } +} + +private func isPermissionDenied(_ error: Error) -> Bool { + let nsError = error as NSError + if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoPermissionError { + return true } + let message = nsError.localizedDescription.lowercased() + return message.contains("permission") || message.contains("operation not permitted") || message.contains("authorization denied") } public struct SafariBookmarksPlistParser: Sendable { diff --git a/Sources/ICloudCLICore/SafariFrequentlyVisited.swift b/Sources/ICloudCLICore/SafariFrequentlyVisited.swift index c509643..275bd16 100644 --- a/Sources/ICloudCLICore/SafariFrequentlyVisited.swift +++ b/Sources/ICloudCLICore/SafariFrequentlyVisited.swift @@ -15,12 +15,14 @@ public struct SafariFrequentlyVisitedSite: Codable, Equatable, Sendable { public enum SafariFrequentlyVisitedError: Error, LocalizedError, Equatable { case missingTopSites(String) case unreadableTopSites(String) + case permissionDenied(String) case noSitesFound(String) public var errorDescription: String? { switch self { case .missingTopSites(let path): return "No Safari frequently visited sites file found: \(path)" case .unreadableTopSites(let path): return "Could not read Safari frequently visited sites file: \(path)" + case .permissionDenied(let path): return "Permission denied reading Safari frequently visited sites file: \(path). Grant Full Disk Access to the calling terminal or agent process, then retry." case .noSitesFound(let path): return "Safari frequently visited sites file did not contain web URLs: \(path)" } } @@ -34,7 +36,14 @@ public struct SafariFrequentlyVisitedReader: Sendable { let fileURL = safariDirectory.appendingPathComponent("TopSites.plist") guard FileManager.default.fileExists(atPath: fileURL.path) else { throw SafariFrequentlyVisitedError.missingTopSites(fileURL.path) } let data: Data - do { data = try Data(contentsOf: fileURL) } catch { throw SafariFrequentlyVisitedError.unreadableTopSites(fileURL.path) } + do { + data = try Data(contentsOf: fileURL) + } catch { + if isPermissionDenied(error) { + throw SafariFrequentlyVisitedError.permissionDenied(fileURL.path) + } + throw SafariFrequentlyVisitedError.unreadableTopSites(fileURL.path) + } let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) let sites = SafariFrequentlyVisitedPlistParser().parse(plist) guard !sites.isEmpty else { throw SafariFrequentlyVisitedError.noSitesFound(fileURL.path) } @@ -42,6 +51,15 @@ public struct SafariFrequentlyVisitedReader: Sendable { } } +private func isPermissionDenied(_ error: Error) -> Bool { + let nsError = error as NSError + if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoPermissionError { + return true + } + let message = nsError.localizedDescription.lowercased() + return message.contains("permission") || message.contains("operation not permitted") || message.contains("authorization denied") +} + public struct SafariFrequentlyVisitedPlistParser: Sendable { public init() {} public func parse(_ plist: Any) -> [SafariFrequentlyVisitedSite] { diff --git a/Sources/ICloudCLICore/SafariTabs.swift b/Sources/ICloudCLICore/SafariTabs.swift index 1a37fd8..43811cf 100644 --- a/Sources/ICloudCLICore/SafariTabs.swift +++ b/Sources/ICloudCLICore/SafariTabs.swift @@ -26,6 +26,7 @@ public enum SafariTabsError: Error, LocalizedError, Equatable { case noReadableSources([String]) case noTabsFound([String]) case noTabsFoundWithUnreadableSources(readable: [String], unreadable: [String]) + case permissionDenied([String]) case unsupportedFormat(String) public var errorDescription: String? { @@ -39,6 +40,8 @@ public enum SafariTabsError: Error, LocalizedError, Equatable { Readable Safari session files did not contain tabs: \(readable.joined(separator: ", ")); \ unreadable Safari session files: \(unreadable.joined(separator: ", ")) """ + case .permissionDenied(let paths): + return "Permission denied reading Safari session files: \(paths.joined(separator: ", ")). Grant Full Disk Access to the calling terminal or agent process, then retry." case .unsupportedFormat(let format): return "Unsupported output format: \(format)" } @@ -55,6 +58,7 @@ public struct SafariTabsReader: Sendable { public func readTabs(source: SafariTabSource = .all) throws -> [SafariTab] { let files = sessionFiles(for: source) var unreadablePaths: [String] = [] + var permissionDeniedPaths: [String] = [] var readablePaths: [String] = [] var tabs: [SafariTab] = [] @@ -63,21 +67,28 @@ public struct SafariTabsReader: Sendable { let sourceTabs = try readTabs(from: file.url, sourceName: file.sourceName) readablePaths.append(file.url.path) tabs.append(contentsOf: sourceTabs) - } catch CocoaError.fileReadNoSuchFile, CocoaError.fileReadNoPermission { + } catch CocoaError.fileReadNoSuchFile { unreadablePaths.append(file.url.path) } catch { - unreadablePaths.append(file.url.path) + if isPermissionDenied(error) { + permissionDeniedPaths.append(file.url.path) + } else { + unreadablePaths.append(file.url.path) + } } } if tabs.isEmpty && !files.isEmpty { if readablePaths.isEmpty { + if !permissionDeniedPaths.isEmpty { + throw SafariTabsError.permissionDenied(permissionDeniedPaths) + } throw SafariTabsError.noReadableSources(unreadablePaths) } - if !unreadablePaths.isEmpty { + if !unreadablePaths.isEmpty || !permissionDeniedPaths.isEmpty { throw SafariTabsError.noTabsFoundWithUnreadableSources( readable: readablePaths, - unreadable: unreadablePaths + unreadable: unreadablePaths + permissionDeniedPaths ) } throw SafariTabsError.noTabsFound(readablePaths) @@ -128,6 +139,15 @@ public struct SafariTabsReader: Sendable { } } +private func isPermissionDenied(_ error: Error) -> Bool { + let nsError = error as NSError + if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoPermissionError { + return true + } + let message = nsError.localizedDescription.lowercased() + return message.contains("permission") || message.contains("operation not permitted") || message.contains("authorization denied") +} + public struct SafariSessionPlistParser: Sendable { public let sourceName: String diff --git a/Sources/ICloudCLICore/ShortcutsInventory.swift b/Sources/ICloudCLICore/ShortcutsInventory.swift index e54e9e1..9318b14 100644 --- a/Sources/ICloudCLICore/ShortcutsInventory.swift +++ b/Sources/ICloudCLICore/ShortcutsInventory.swift @@ -10,10 +10,12 @@ public struct ShortcutEntry: Codable, Equatable, Sendable { public enum ShortcutsInventoryError: Error, LocalizedError, Equatable { case missingDirectory(String) + case permissionDenied(String) public var errorDescription: String? { switch self { case .missingDirectory(let path): return "Shortcuts directory not available: \(path)" + case .permissionDenied(let path): return "Permission denied reading Shortcuts directory: \(path). Grant Full Disk Access to the calling terminal or agent process, then retry." } } } @@ -30,7 +32,14 @@ public struct ShortcutsInventoryReader: Sendable { throw ShortcutsInventoryError.missingDirectory(shortcutsDirectory.path) } - let entries = try FileManager.default.contentsOfDirectory(at: shortcutsDirectory, includingPropertiesForKeys: [.isDirectoryKey, .creationDateKey, .contentModificationDateKey], options: []) + let entries: [URL] + do { + entries = try FileManager.default.contentsOfDirectory(at: shortcutsDirectory, includingPropertiesForKeys: [.isDirectoryKey, .creationDateKey, .contentModificationDateKey], options: []) + } catch CocoaError.fileReadNoPermission { + throw ShortcutsInventoryError.permissionDenied(shortcutsDirectory.path) + } catch { + throw error + } var shortcuts: [ShortcutEntry] = [] for entry in entries where entry.pathExtension == "shortcut" { if let shortcut = try readShortcut(at: entry) { diff --git a/Tests/ICloudCLICoreTests/BroadIssueInventoryTests.swift b/Tests/ICloudCLICoreTests/BroadIssueInventoryTests.swift index b444033..f8ef159 100644 --- a/Tests/ICloudCLICoreTests/BroadIssueInventoryTests.swift +++ b/Tests/ICloudCLICoreTests/BroadIssueInventoryTests.swift @@ -120,6 +120,273 @@ import Testing #expect(recent.first?.string("subject") == "Hello") } +@Test func readsAppleCalendarEventKitShape() throws { + let root = try temporaryDirectoryForBroadIssues(named: "calendar-eventkit") + let database = root.appendingPathComponent("Calendar.sqlitedb") + defer { try? FileManager.default.removeItem(at: root) } + let sql = """ + CREATE TABLE Store (ROWID INTEGER PRIMARY KEY, name TEXT, type INTEGER, disabled INTEGER, delegated_account_default_calendar_for_new_events_id INTEGER); + CREATE TABLE Calendar (ROWID INTEGER PRIMARY KEY, store_id INTEGER, title TEXT, flags INTEGER, color TEXT, type TEXT); + CREATE TABLE CalendarItem (ROWID INTEGER PRIMARY KEY, summary TEXT, calendar_id INTEGER, start_date REAL, end_date REAL, all_day INTEGER, hidden INTEGER, description TEXT); + CREATE TABLE Participant (ROWID INTEGER PRIMARY KEY, owner_id INTEGER, email TEXT, phone_number TEXT); + INSERT INTO Store VALUES (1, 'iCloud', 3, 0, 1); + INSERT INTO Calendar VALUES (1, 1, 'Work', 0, '#E6C800FF', 'com.apple.ical.sources.caldav'); + INSERT INTO CalendarItem VALUES (1, 'Planning', 1, 788961600, 788965200, 0, 0, 'private'); + INSERT INTO Participant VALUES (1, 1, 'teammate@example.com', NULL); + """ + try runSQLiteForBroadIssues(database: database, sql: sql) + let reader = LocalMetadataStoreReader(database: database) + + let accounts = try reader.rows(for: .calendarAccounts) + #expect(accounts.first?.string("name") == "iCloud") + #expect(accounts.first?.int("calendarCount") == 1) + + let calendars = try reader.rows(for: .calendarList) + #expect(calendars.first?.string("title") == "Work") + #expect(calendars.first?.int("isDefault") == 1) + + let events = try reader.rows(for: .calendarEvents, options: MetadataOptions(calendar: "Work", since: "2026-01-01T00:00:00Z", includeAttendees: true, includeNotes: true)) + #expect(events.first?.string("title") == "Planning") + #expect(events.first?.string("attendees") == "teammate@example.com") + #expect(events.first?.string("notes") == "private") +} + +@Test func readsApplePhotosLibraryMetadataShape() throws { + let root = try temporaryDirectoryForBroadIssues(named: "photos-coredata") + let database = root.appendingPathComponent("Photos.sqlite") + defer { try? FileManager.default.removeItem(at: root) } + let sql = """ + CREATE TABLE ZGENERICALBUM ( + Z_PK INTEGER PRIMARY KEY, + ZCACHEDCOUNT INTEGER, + ZCACHEDPHOTOSCOUNT INTEGER, + ZTRASHEDSTATE INTEGER, + ZCLOUDGUID TEXT, + ZTITLE TEXT, + ZCLOUDOWNERFULLNAME TEXT, + ZCLOUDMULTIPLECONTRIBUTORSENABLED INTEGER, + ZCLOUDLASTCONTRIBUTIONDATE REAL + ); + CREATE TABLE ZSHARE ( + Z_PK INTEGER PRIMARY KEY, + ZASSETCOUNT INTEGER, + ZCLOUDITEMCOUNT INTEGER, + ZPHOTOSCOUNT INTEGER, + ZSTATUS INTEGER, + ZSCOPETYPE INTEGER, + ZTRASHEDSTATE INTEGER, + ZTITLE TEXT, + ZSCOPEIDENTIFIER TEXT, + ZUUID TEXT + ); + CREATE TABLE ZSHAREPARTICIPANT (Z_PK INTEGER PRIMARY KEY, ZSHARE INTEGER, Z66_SHARE INTEGER); + INSERT INTO ZGENERICALBUM VALUES (1, 10, 9, 0, 'album-guid', 'Trip', 'Operator', 1, 788961600); + INSERT INTO ZSHARE VALUES (1, 12, NULL, NULL, 1, 2, 0, 'Shared Library', 'scope', 'uuid'); + INSERT INTO ZSHAREPARTICIPANT VALUES (1, 1, NULL); + INSERT INTO ZSHAREPARTICIPANT VALUES (2, NULL, 1); + """ + try runSQLiteForBroadIssues(database: database, sql: sql) + let reader = LocalMetadataStoreReader(database: database) + + let albums = try reader.rows(for: .photosSharedAlbums) + #expect(albums.first?.string("title") == "Trip") + #expect(albums.first?.int("assetCount") == 10) + + let library = try reader.rows(for: .photosSharedLibrary) + #expect(library.first?.string("title") == "Shared Library") + #expect(library.first?.int("participantCount") == 2) +} + +@Test func readsAppleBooksVersionedLibraryShape() throws { + let root = try temporaryDirectoryForBroadIssues(named: "books-coredata") + let database = root.appendingPathComponent("BKLibrary-1-example.sqlite") + defer { try? FileManager.default.removeItem(at: root) } + let sql = """ + CREATE TABLE ZBKCOLLECTION (Z_PK INTEGER PRIMARY KEY, ZDELETEDFLAG INTEGER, ZTITLE TEXT, ZCOLLECTIONID TEXT); + CREATE TABLE ZBKCOLLECTIONMEMBER (Z_PK INTEGER PRIMARY KEY, ZASSET INTEGER, ZCOLLECTION INTEGER); + CREATE TABLE ZBKLIBRARYASSET ( + Z_PK INTEGER PRIMARY KEY, + ZISHIDDEN INTEGER, + ZTITLE TEXT, + ZASSETID TEXT, + ZAUTHOR TEXT, + ZKIND TEXT, + ZREADINGPROGRESS REAL + ); + INSERT INTO ZBKCOLLECTION VALUES (1, 0, 'Reading', 'collection-1'); + INSERT INTO ZBKLIBRARYASSET VALUES (1, 0, 'Example Book', 'asset-1', 'Example Author', 'epub', 0.5); + INSERT INTO ZBKCOLLECTIONMEMBER VALUES (1, 1, 1); + """ + try runSQLiteForBroadIssues(database: database, sql: sql) + let reader = LocalMetadataStoreReader(database: database) + + let collections = try reader.rows(for: .booksCollections) + #expect(collections.first?.string("name") == "Reading") + #expect(collections.first?.int("bookCount") == 1) + + let books = try reader.rows(for: .booksList, options: MetadataOptions(collection: "Reading", includeHighlights: true)) + #expect(books.first?.string("title") == "Example Book") + #expect(books.first?.int("progressPercent") == 50) +} + +@Test func readsAppleNotesMetadataShape() throws { + let database = try appleNotesMetadataDatabaseForBroadIssues() + defer { try? FileManager.default.removeItem(at: database.deletingLastPathComponent()) } + let reader = LocalMetadataStoreReader(database: database) + + let accounts = try reader.rows(for: .notesAccounts) + #expect(accounts.first?.string("name") == "iCloud") + #expect(accounts.first?.int("noteCount") == 1) + + let folders = try reader.rows(for: .notesFolders, options: MetadataOptions(account: "iCloud")) + #expect(folders.first?.string("name") == "Quick Notes") + #expect(folders.first?.int("noteCount") == 1) + + #expect(try reader.rows(for: .notesTags).isEmpty) + + let shared = try reader.rows(for: .notesShared) + #expect(shared.first?.string("title") == "Shared Plan") + #expect(shared.first?.int("noteCount") == 1) +} + +@Test func readsAppleCloudTabsShapeWithoutUrlsByDefault() throws { + let root = try temporaryDirectoryForBroadIssues(named: "cloud-tabs-real") + let database = root.appendingPathComponent("CloudTabs.db") + defer { try? FileManager.default.removeItem(at: root) } + let sql = """ + CREATE TABLE cloud_tab_devices (device_uuid TEXT PRIMARY KEY, system_fields BLOB, device_name TEXT, has_duplicate_device_name INTEGER, is_ephemeral_device INTEGER, last_modified TEXT); + CREATE TABLE cloud_tabs (tab_uuid TEXT PRIMARY KEY, system_fields BLOB, device_uuid TEXT, position INTEGER, title TEXT, url TEXT, is_showing_reader INTEGER, is_pinned INTEGER, reader_scroll_position_page_index INTEGER, scene_id TEXT); + INSERT INTO cloud_tab_devices VALUES ('device-1', X'00', 'Example iPhone', 0, 0, '2026-05-10T09:00:00Z'); + INSERT INTO cloud_tabs VALUES ('tab-1', X'01', 'device-1', 1, 'Example Page', 'https://example.com/private/path', 0, 0, NULL, 'scene-1'); + """ + try runSQLiteForBroadIssues(database: database, sql: sql) + let reader = LocalMetadataStoreReader(database: database) + + let redacted = try reader.rows(for: .safariCloudTabsList, options: MetadataOptions(confirmSensitive: true)) + #expect(redacted.first?.string("title") == "Example Page") + #expect(redacted.first?.string("url") == nil) + + let withURL = try reader.rows(for: .safariCloudTabsList, options: MetadataOptions(confirmSensitive: true, includeURLs: true)) + #expect(withURL.first?.string("url") == "https://example.com") +} + +@Test func readsAppleRemindersMetadataShape() throws { + let root = try temporaryDirectoryForBroadIssues(named: "reminders-coredata") + let database = root.appendingPathComponent("Data-example.sqlite") + defer { try? FileManager.default.removeItem(at: root) } + let sql = """ + CREATE TABLE ZREMCDBASELIST ( + Z_PK INTEGER PRIMARY KEY, + ZMARKEDFORDELETION INTEGER, + ZNAME TEXT + ); + CREATE TABLE ZREMCDREMINDER ( + Z_PK INTEGER PRIMARY KEY, + ZMARKEDFORDELETION INTEGER, + ZCOMPLETED INTEGER, + ZFLAGGED INTEGER, + ZPRIORITY INTEGER, + ZLIST INTEGER, + ZTITLE TEXT, + ZNOTES TEXT, + ZDUEDATE REAL, + ZCREATIONDATE REAL + ); + CREATE TABLE ZREMCDOBJECT ( + Z_PK INTEGER PRIMARY KEY, + ZREMINDER INTEGER, + ZREMINDER1 INTEGER, + ZREMINDER2 INTEGER, + Z_FOK_REMINDER INTEGER, + ZASSIGNEE INTEGER + ); + INSERT INTO ZREMCDBASELIST VALUES (1, 0, 'Work'); + INSERT INTO ZREMCDREMINDER VALUES (1, 0, 0, 1, 5, 1, 'Ship PR', 'Review', 788961600, 788875200); + INSERT INTO ZREMCDOBJECT VALUES (1, 1, NULL, NULL, NULL, 7); + """ + try runSQLiteForBroadIssues(database: database, sql: sql) + let reader = LocalMetadataStoreReader(database: database) + + let flagged = try reader.rows(for: .remindersFlagged, options: MetadataOptions(includeNotes: true)) + #expect(flagged.first?.string("title") == "Ship PR") + #expect(flagged.first?.string("notes") == "Review") + + let scheduled = try reader.rows(for: .remindersScheduled, options: MetadataOptions(since: "2026-01-01T00:00:00Z", until: "2027-01-01T00:00:00Z")) + #expect(scheduled.first?.string("notes") == nil) + #expect(scheduled.first?.int("isFlagged") == 1) + + let assigned = try reader.rows(for: .remindersAssigned) + #expect(assigned.first?.int("assignedToMe") == 1) +} + +@Test func readsAppleHomeKitCoreShape() throws { + let root = try temporaryDirectoryForBroadIssues(named: "homekit-core") + let database = root.appendingPathComponent("core.sqlite") + defer { try? FileManager.default.removeItem(at: root) } + let sql = """ + CREATE TABLE ZMKFHOME ( + Z_PK INTEGER PRIMARY KEY, + ZNAME TEXT, + ZOWNED INTEGER + ); + CREATE TABLE ZMKFROOM ( + Z_PK INTEGER PRIMARY KEY, + ZHOME INTEGER, + ZNAME TEXT + ); + CREATE TABLE ZMKFACCESSORY ( + Z_PK INTEGER PRIMARY KEY, + ZHOME INTEGER, + ZROOM INTEGER, + ZCONFIGUREDNAME TEXT, + ZPROVIDEDNAME TEXT, + ZMANUFACTURER TEXT, + ZMODEL TEXT, + ZACCESSORYCATEGORY INTEGER, + ZHOSTACCESSORY INTEGER + ); + CREATE TABLE ZMKFACTIONSET ( + Z_PK INTEGER PRIMARY KEY, + ZHOME INTEGER, + ZNAME TEXT + ); + INSERT INTO ZMKFHOME VALUES (1, 'House', 1); + INSERT INTO ZMKFROOM VALUES (1, 1, 'Office'); + INSERT INTO ZMKFACCESSORY VALUES (1, 1, 1, 'Lamp', 'Provided Lamp', 'Example', 'A1', 5, NULL); + INSERT INTO ZMKFACTIONSET VALUES (1, 1, 'Work'); + """ + try runSQLiteForBroadIssues(database: database, sql: sql) + let reader = LocalMetadataStoreReader(database: database) + + let homes = try reader.rows(for: .homeHomes) + #expect(homes.first?.string("name") == "House") + #expect(homes.first?.int("primaryFlag") == 1) + + let rooms = try reader.rows(for: .homeRooms, options: MetadataOptions(home: "House")) + #expect(rooms.first?.string("name") == "Office") + #expect(rooms.first?.int("accessoryCount") == 1) + + let accessories = try reader.rows(for: .homeAccessories, options: MetadataOptions(home: "House", room: "Office")) + #expect(accessories.first?.string("name") == "Lamp") + #expect(accessories.first?.string("manufacturer") == "Example") + + let scenes = try reader.rows(for: .homeScenes, options: MetadataOptions(home: "House")) + #expect(scenes.first?.string("name") == "Work") +} + +@Test func finderTagsResolverFallsBackToPreferencesStore() throws { + let root = try temporaryDirectoryForBroadIssues(named: "finder-tags-resolver") + let synced = root.appendingPathComponent("SyncedPreferences/com.apple.finder.plist") + let preferences = root.appendingPathComponent("Preferences/com.apple.finder.plist") + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: preferences.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data().write(to: preferences) + + let resolved = FinderTagsStoreResolver(syncedFile: synced, preferencesFile: preferences).resolvedPreferencesFile() + + #expect(resolved.path == preferences.path) +} + @Test func reportsUnsupportedMusicStoreWhenNotSQLite() throws { let root = try temporaryDirectoryForBroadIssues(named: "music-nonsqlite") let database = root.appendingPathComponent("Library.musicdb") @@ -341,6 +608,29 @@ private func syntheticBroadInventoryDatabase() throws -> URL { return database } +private func appleNotesMetadataDatabaseForBroadIssues() throws -> URL { + let root = try temporaryDirectoryForBroadIssues(named: "notes-coredata") + let database = root.appendingPathComponent("NoteStore.sqlite") + let sql = """ + CREATE TABLE ZICCLOUDSYNCINGOBJECT ( + Z_PK INTEGER PRIMARY KEY, + ZTITLE1 TEXT, + ZTITLE2 TEXT, + ZNAME TEXT, + ZFOLDER INTEGER, + ZMARKEDFORDELETION INTEGER, + ZISSHAREDIRTY INTEGER, + ZACCOUNTNAMEFORACCOUNTLISTSORTING TEXT + ); + CREATE TABLE ZICINVITATION (Z_PK INTEGER PRIMARY KEY, ZTITLE TEXT, ZNOTECOUNT INTEGER, ZSHAREURL TEXT); + INSERT INTO ZICCLOUDSYNCINGOBJECT VALUES (1, NULL, 'Quick Notes', NULL, NULL, 0, 0, 'iCloud'); + INSERT INTO ZICCLOUDSYNCINGOBJECT VALUES (2, 'Plan', NULL, NULL, 1, 0, 0, 'iCloud'); + INSERT INTO ZICINVITATION VALUES (1, 'Shared Plan', 1, 'https://example.com/share'); + """ + try runSQLiteForBroadIssues(database: database, sql: sql) + return database +} + private func syntheticAccountPlist() throws -> URL { let root = try temporaryDirectoryForBroadIssues(named: "account-plist") let plist = root.appendingPathComponent("MobileMeAccounts.plist") diff --git a/Tests/ICloudCLICoreTests/CLIParserTests.swift b/Tests/ICloudCLICoreTests/CLIParserTests.swift index 36a9314..02e4782 100644 --- a/Tests/ICloudCLICoreTests/CLIParserTests.swift +++ b/Tests/ICloudCLICoreTests/CLIParserTests.swift @@ -142,6 +142,45 @@ import Testing #expect(options.rootDirectory.path == "/tmp/mobile-documents") } +@Test func parsesDriveStatusCommandWithoutDefaultTraversalLimit() throws { + let command = try CLIParser().parse(arguments: ["icloud-cli", "drive", "status", "--icloud-root", "/tmp/mobile-documents"]) + + guard case .metadata(let metadataCommand, let options) = command else { + Issue.record("Expected metadata command") + return + } + + #expect(metadataCommand == .driveStatus) + #expect(options.limit == 50) + #expect(options.driveStatusLimit == nil) + #expect(options.rootDirectory?.path == "/tmp/mobile-documents") +} + +@Test func parsesDriveStatusExplicitLimitSeparately() throws { + let command = try CLIParser().parse(arguments: ["icloud-cli", "drive", "status", "--limit", "1", "--icloud-root", "/tmp/mobile-documents"]) + + guard case .metadata(let metadataCommand, let options) = command else { + Issue.record("Expected metadata command") + return + } + + #expect(metadataCommand == .driveStatus) + #expect(options.limit == 1) + #expect(options.driveStatusLimit == 1) +} + +@Test func parsesHandoffListLimit() throws { + let command = try CLIParser().parse(arguments: ["icloud-cli", "handoff", "list", "--limit", "3", "--handoff-dir", "/tmp/handoff"]) + + guard case .handoffList(let options) = command else { + Issue.record("Expected handoff list command") + return + } + + #expect(options.limit == 3) + #expect(options.handoffDirectory.path == "/tmp/handoff") +} + @Test func parsesShortcutsListCommand() throws { let command = try CLIParser().parse(arguments: ["icloud-cli", "shortcuts", "list", "--name", "Daily", "--format", "text", "--shortcuts-dir", "/tmp/shortcuts"]) diff --git a/Tests/ICloudCLICoreTests/CommandRunnerHarnessTests.swift b/Tests/ICloudCLICoreTests/CommandRunnerHarnessTests.swift index bd627cf..42422b8 100644 --- a/Tests/ICloudCLICoreTests/CommandRunnerHarnessTests.swift +++ b/Tests/ICloudCLICoreTests/CommandRunnerHarnessTests.swift @@ -57,6 +57,101 @@ import Testing #expect(sink.output[1] == CLIHelp.version) } +@Test func commandRunnerHarnessCoversDocumentedMetadataFamilies() throws { + let fixtureRoot = testFixturesRoot() + let tempRoot = try temporaryHarnessDirectory(named: "metadata") + defer { try? FileManager.default.removeItem(at: tempRoot) } + + let database = try syntheticFullMetadataDatabase(in: tempRoot) + let finderTags = try syntheticFinderTagsPreferences(in: tempRoot) + let cacheDirectory = tempRoot.appendingPathComponent("cache") + let snapshotOutput = tempRoot.appendingPathComponent("snapshot.json") + let sink = CommandRunnerHarnessSink() + let runner = CommandRunner(output: { sink.appendOutput($0) }, errorOutput: { sink.appendError($0) }) + + let commands: [[String]] = [ + ["icloud-cli", "snapshot", "--include", "cache-status", "--output", snapshotOutput.path, "--format", "json"], + ["icloud-cli", "account", "status", "--cache-file", fixtureRoot.appendingPathComponent("SystemStatus/MobileMeAccounts.plist").path], + ["icloud-cli", "backup", "status", "--cache-file", fixtureRoot.appendingPathComponent("SystemStatus/MobileMeAccounts.plist").path], + ["icloud-cli", "family", "status", "--cache-file", fixtureRoot.appendingPathComponent("SystemStatus/MobileMeAccounts.plist").path], + ["icloud-cli", "drive", "status", "--icloud-root", fixtureRoot.appendingPathComponent("MobileDocuments").path], + ["icloud-cli", "drive", "errors", "--icloud-root", fixtureRoot.appendingPathComponent("MobileDocuments").path], + ["icloud-cli", "drive", "shared", "--icloud-root", fixtureRoot.appendingPathComponent("MobileDocuments").path], + ["icloud-cli", "drive", "recents", "--icloud-root", fixtureRoot.appendingPathComponent("MobileDocuments").path], + ["icloud-cli", "photos", "shared-albums", "--photos-store", database.path], + ["icloud-cli", "photos", "shared-library", "--photos-store", database.path], + ["icloud-cli", "notes", "accounts", "--notes-store", database.path], + ["icloud-cli", "notes", "folders", "--notes-store", database.path], + ["icloud-cli", "notes", "tags", "--notes-store", database.path], + ["icloud-cli", "notes", "shared", "--notes-store", database.path], + ["icloud-cli", "reminders", "flagged", "--reminders-store", database.path], + ["icloud-cli", "reminders", "today", "--reminders-store", database.path], + ["icloud-cli", "reminders", "scheduled", "--reminders-store", database.path], + ["icloud-cli", "reminders", "assigned", "--reminders-store", database.path], + ["icloud-cli", "calendar", "accounts", "--calendar-store", database.path], + ["icloud-cli", "calendar", "list", "--calendar-store", database.path], + ["icloud-cli", "calendar", "events", "--calendar-store", database.path], + ["icloud-cli", "findmy", "devices", "--findmy-store", database.path], + ["icloud-cli", "findmy", "people", "--findmy-store", database.path], + ["icloud-cli", "mail", "accounts", "--mail-store", database.path], + ["icloud-cli", "mail", "mailboxes", "--mail-store", database.path], + ["icloud-cli", "mail", "recent", "--confirm-sensitive", "--mail-store", database.path], + ["icloud-cli", "books", "collections", "--books-store", database.path], + ["icloud-cli", "books", "list", "--books-store", database.path], + ["icloud-cli", "voice-memos", "list", "--voice-memos-store", database.path], + ["icloud-cli", "home", "homes", "--home-store", database.path], + ["icloud-cli", "home", "rooms", "--home-store", database.path], + ["icloud-cli", "home", "accessories", "--home-store", database.path], + ["icloud-cli", "home", "scenes", "--home-store", database.path], + ["icloud-cli", "health", "summary", "--confirm-sensitive", "--health-store", database.path], + ["icloud-cli", "freeform", "list", "--freeform-store", database.path], + ["icloud-cli", "music", "status", "--music-store", database.path], + ["icloud-cli", "music", "playlists", "--music-store", database.path], + ["icloud-cli", "music", "tracks", "--music-store", database.path], + ["icloud-cli", "stocks", "watchlist", "--stocks-store", database.path], + ["icloud-cli", "stocks", "groups", "--stocks-store", database.path], + ["icloud-cli", "weather", "favorites", "--weather-store", database.path], + ["icloud-cli", "tags", "list", "--store", finderTags.path], + ["icloud-cli", "tags", "items", "--tag", "report", "--icloud-root", fixtureRoot.appendingPathComponent("MobileDocuments").path], + ["icloud-cli", "permissions", "doctor"], + ["icloud-cli", "safari", "cloud-tabs", "list", "--confirm-sensitive", "--safari-store", database.path], + ["icloud-cli", "safari", "profiles", "list", "--safari-store", database.path], + ["icloud-cli", "safari", "extensions", "list", "--safari-store", database.path], + ["icloud-cli", "watch", "--once", "--commands", "unknown-command", "--output-dir", cacheDirectory.path], + ["icloud-cli", "cache", "read", "unknown-command", "--output-dir", cacheDirectory.path], + ] + + for command in commands { + #expect(runner.run(arguments: command) == 0, "Expected success for \(command.joined(separator: " "))") + } + + #expect(FileManager.default.fileExists(atPath: snapshotOutput.path)) + #expect(sink.errors.isEmpty) + #expect(sink.output.count == commands.count) + #expect(sink.output.last?.contains("Unsupported cache command: unknown-command") == true) +} + +@Test func driveStatusCommandHonorsExplicitTraversalLimit() throws { + let tempRoot = try temporaryHarnessDirectory(named: "drive-status-limit") + defer { try? FileManager.default.removeItem(at: tempRoot) } + + let driveRoot = tempRoot.appendingPathComponent("MobileDocuments") + let container = driveRoot.appendingPathComponent("com~example~App") + try FileManager.default.createDirectory(at: container, withIntermediateDirectories: true) + try Data("ok".utf8).write(to: container.appendingPathComponent("one.txt")) + try Data("ok".utf8).write(to: container.appendingPathComponent("two.txt")) + try Data("ok".utf8).write(to: container.appendingPathComponent(".broken.txt.icloud")) + + let sink = CommandRunnerHarnessSink() + let runner = CommandRunner(output: { sink.appendOutput($0) }, errorOutput: { sink.appendError($0) }) + + #expect(runner.run(arguments: ["icloud-cli", "drive", "status", "--icloud-root", driveRoot.path, "--limit", "1"]) == 0) + + let output = try #require(sink.output.first) + let summary = try JSONDecoder().decode(ICloudDriveSyncSummary.self, from: Data(output.utf8)) + #expect(summary.downloadedCount + summary.cloudOnlyCount + summary.downloadingCount + summary.uploadedCount + summary.uploadingCount + summary.errorCount + summary.unknownCount == 1) +} + @Test func commandRunnerHarnessReportsExpectedSafetyAndStoreFailures() throws { let database = try syntheticDirectInventoryDatabase(in: try temporaryHarnessDirectory(named: "failures")) defer { try? FileManager.default.removeItem(at: database.deletingLastPathComponent()) } @@ -81,6 +176,34 @@ import Testing #expect(sink.errors[2].contains("No cached output found")) } +@Test func driveStatusDefaultsToFullTraversalAndHonorsExplicitLimit() throws { + let root = try temporaryHarnessDirectory(named: "drive-status-limit") + defer { try? FileManager.default.removeItem(at: root) } + + let driveRoot = root.appendingPathComponent("MobileDocuments") + try FileManager.default.createDirectory(at: driveRoot.appendingPathComponent("com~apple~CloudDocs/Docs"), withIntermediateDirectories: true) + try Data("one".utf8).write(to: driveRoot.appendingPathComponent("com~apple~CloudDocs/Docs/a.txt")) + try Data("two".utf8).write(to: driveRoot.appendingPathComponent("com~apple~CloudDocs/Docs/b.txt")) + + let sink = CommandRunnerHarnessSink() + let runner = CommandRunner(output: { sink.appendOutput($0) }, errorOutput: { sink.appendError($0) }) + + let fullExit = runner.run(arguments: ["icloud-cli", "drive", "status", "--icloud-root", driveRoot.path]) + let cappedExit = runner.run(arguments: ["icloud-cli", "drive", "status", "--limit", "1", "--icloud-root", driveRoot.path]) + + #expect(fullExit == 0) + #expect(cappedExit == 0) + #expect(sink.errors.isEmpty) + #expect(sink.output.count == 2) + + let decoder = JSONDecoder() + let full = try decoder.decode(ICloudDriveSyncSummary.self, from: Data(sink.output[0].utf8)) + let capped = try decoder.decode(ICloudDriveSyncSummary.self, from: Data(sink.output[1].utf8)) + + #expect(full.downloadedCount + full.cloudOnlyCount + full.downloadingCount + full.uploadedCount + full.uploadingCount + full.errorCount + full.unknownCount == 2) + #expect(capped.downloadedCount + capped.cloudOnlyCount + capped.downloadingCount + capped.uploadedCount + capped.uploadingCount + capped.errorCount + capped.unknownCount == 1) +} + private func testFixturesRoot() -> URL { URL(fileURLWithPath: #filePath) .deletingLastPathComponent() @@ -126,6 +249,92 @@ private func syntheticDirectInventoryDatabase(in root: URL) throws -> URL { return database } +private func syntheticFullMetadataDatabase(in root: URL) throws -> URL { + let database = try syntheticDirectInventoryDatabase(in: root) + let sql = """ + ALTER TABLE reminders ADD COLUMN isFlagged INTEGER DEFAULT 1; + ALTER TABLE reminders ADD COLUMN assignedToMe INTEGER DEFAULT 1; + CREATE TABLE calendar_accounts (name TEXT, type TEXT, isEnabled INTEGER); + INSERT INTO calendar_accounts VALUES ('iCloud', 'caldav', 1); + CREATE TABLE calendar_calendars (account TEXT, calendar TEXT, color TEXT, isVisible INTEGER); + INSERT INTO calendar_calendars VALUES ('iCloud', 'Work', 'blue', 1); + CREATE TABLE calendar_events (calendar TEXT, title TEXT, startsAt TEXT, endsAt TEXT, attendees TEXT, notes TEXT); + INSERT INTO calendar_events VALUES ('Work', 'Planning', '2026-01-01T12:00:00Z', '2026-01-01T13:00:00Z', 'alice@example.com', 'Synthetic note'); + CREATE TABLE findmy_devices (name TEXT, model TEXT, batteryLevel INTEGER, latitude REAL, longitude REAL); + INSERT INTO findmy_devices VALUES ('Example iPhone', 'iPhone', 80, 0.0, 0.0); + CREATE TABLE findmy_people (name TEXT, relationship TEXT, latitude REAL, longitude REAL); + INSERT INTO findmy_people VALUES ('Example Person', 'friend', 0.0, 0.0); + CREATE TABLE mail_accounts (account TEXT, type TEXT, mailboxCount INTEGER); + INSERT INTO mail_accounts VALUES ('iCloud', 'IMAP', 2); + CREATE TABLE mail_mailboxes (account TEXT, mailbox TEXT, totalCount INTEGER, unreadCount INTEGER); + INSERT INTO mail_mailboxes VALUES ('iCloud', 'Inbox', 10, 1); + CREATE TABLE mail_recent (account TEXT, mailbox TEXT, sender TEXT, subject TEXT, sentAt TEXT); + INSERT INTO mail_recent VALUES ('iCloud', 'Inbox', 'sender@example.com', 'Synthetic subject', '2026-01-01T12:00:00Z'); + CREATE TABLE books_collections (collection TEXT, itemCount INTEGER); + INSERT INTO books_collections VALUES ('Library', 1); + CREATE TABLE books (title TEXT, author TEXT, collection TEXT, highlightCount INTEGER); + INSERT INTO books VALUES ('Example Book', 'Example Author', 'Library', 2); + CREATE TABLE voice_memos (title TEXT, folder TEXT, modifiedAt TEXT, durationSeconds INTEGER); + INSERT INTO voice_memos VALUES ('Example Recording', 'All Recordings', '2026-01-01T12:00:00Z', 30); + CREATE TABLE home_homes (home TEXT, accessoryCount INTEGER); + INSERT INTO home_homes VALUES ('Home', 1); + CREATE TABLE home_rooms (home TEXT, room TEXT); + INSERT INTO home_rooms VALUES ('Home', 'Office'); + CREATE TABLE home_accessories (home TEXT, room TEXT, name TEXT, category TEXT); + INSERT INTO home_accessories VALUES ('Home', 'Office', 'Lamp', 'Light'); + CREATE TABLE home_scenes (home TEXT, scene TEXT); + INSERT INTO home_scenes VALUES ('Home', 'Work'); + CREATE TABLE health_summary (metric TEXT, count INTEGER, lastUpdatedAt TEXT); + INSERT INTO health_summary VALUES ('workouts', 1, '2026-01-01T12:00:00Z'); + CREATE TABLE freeform_boards (title TEXT, folder TEXT, modifiedAt TEXT); + INSERT INTO freeform_boards VALUES ('Example Board', 'Boards', '2026-01-01T12:00:00Z'); + CREATE TABLE music_status (metric TEXT, value TEXT); + INSERT INTO music_status VALUES ('library', 'available'); + CREATE TABLE music_playlists (playlist TEXT, trackCount INTEGER); + INSERT INTO music_playlists VALUES ('Example Playlist', 1); + CREATE TABLE music_tracks (title TEXT, artist TEXT, playlist TEXT, cloudStatus TEXT); + INSERT INTO music_tracks VALUES ('Example Track', 'Example Artist', 'Example Playlist', 'downloaded'); + CREATE TABLE stocks_watchlist (symbol TEXT, name TEXT); + INSERT INTO stocks_watchlist VALUES ('AAPL', 'Apple Inc.'); + CREATE TABLE stocks_groups (name TEXT, symbolCount INTEGER); + INSERT INTO stocks_groups VALUES ('Technology', 1); + CREATE TABLE weather_favorites (name TEXT, latitude REAL, longitude REAL); + INSERT INTO weather_favorites VALUES ('Cupertino', 0.0, 0.0); + CREATE TABLE photos_shared_albums (title TEXT, assetCount INTEGER); + INSERT INTO photos_shared_albums VALUES ('Shared', 1); + CREATE TABLE photos_shared_library (status TEXT, participantCount INTEGER); + INSERT INTO photos_shared_library VALUES ('enabled', 2); + CREATE TABLE notes_accounts (account TEXT, folderCount INTEGER); + INSERT INTO notes_accounts VALUES ('iCloud', 1); + CREATE TABLE notes_folders (account TEXT, folder TEXT, noteCount INTEGER); + INSERT INTO notes_folders VALUES ('iCloud', 'Quick Notes', 1); + CREATE TABLE notes_tags (tag TEXT, noteCount INTEGER); + INSERT INTO notes_tags VALUES ('project', 1); + CREATE TABLE notes_shared (title TEXT, participantCount INTEGER); + INSERT INTO notes_shared VALUES ('Shared Note', 2); + CREATE TABLE cloud_tab_devices (device_uuid TEXT, device_name TEXT, last_modified TEXT); + INSERT INTO cloud_tab_devices VALUES ('device-1', 'Example Mac', '2026-01-01T12:00:00Z'); + CREATE TABLE cloud_tabs (device_uuid TEXT, title TEXT, url TEXT, position INTEGER, is_pinned INTEGER, is_showing_reader INTEGER, scene_id TEXT); + INSERT INTO cloud_tabs VALUES ('device-1', 'Example', 'https://example.com/private', 1, 0, 0, 'scene-1'); + CREATE TABLE safari_profiles (profile TEXT, displayName TEXT); + INSERT INTO safari_profiles VALUES ('Default', 'Default'); + CREATE TABLE safari_extensions (profile TEXT, name TEXT, isEnabled INTEGER); + INSERT INTO safari_extensions VALUES ('Default', 'Example Extension', 1); + """ + try runSQLiteForCommandRunnerHarness(database: database, sql: sql) + return database +} + +private func syntheticFinderTagsPreferences(in root: URL) throws -> URL { + let preferences = root.appendingPathComponent("finder-tags.plist") + let plist: NSDictionary = [ + "FavoriteTagNames": ["report"], + "TagColorDictionary": ["report": "red"], + ] + #expect(plist.write(to: preferences, atomically: true)) + return preferences +} + private func runSQLiteForCommandRunnerHarness(database: URL, sql: String) throws { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3") diff --git a/Tests/ICloudCLICoreTests/DriveInventoryTests.swift b/Tests/ICloudCLICoreTests/DriveInventoryTests.swift index de2c8cb..4791e08 100644 --- a/Tests/ICloudCLICoreTests/DriveInventoryTests.swift +++ b/Tests/ICloudCLICoreTests/DriveInventoryTests.swift @@ -15,6 +15,67 @@ private func mobileDocumentsFixtureURL() throws -> URL { #expect(files.contains { $0.path == "com~apple~CloudDocs/Documents/.draft.md.icloud" && $0.name == "draft.md" && $0.iCloudStatus == .evicted && $0.sizeBytes == nil }) } +@Test func limitsICloudDriveFileWalks() throws { + let files = try ICloudDriveInventoryReader(rootDirectory: try mobileDocumentsFixtureURL()).listFiles(depth: Int.max, limit: 1) + + #expect(files.count == 1) +} + +@Test func countsDriveStatusAcrossAllFilesBeforeApplyingListLimits() throws { + let root = try temporaryDriveInventoryRoot(named: "status-counts") + defer { try? FileManager.default.removeItem(at: root) } + + let driveRoot = root.appendingPathComponent("Library/Mobile Documents") + try FileManager.default.createDirectory(at: driveRoot.appendingPathComponent("com~example~App"), withIntermediateDirectories: true) + try Data("ok".utf8).write(to: driveRoot.appendingPathComponent("com~example~App/one.txt")) + try Data("ok".utf8).write(to: driveRoot.appendingPathComponent("com~example~App/two.txt")) + try Data("ok".utf8).write(to: driveRoot.appendingPathComponent("com~example~App/.broken.txt.icloud")) + + let reader = ICloudDriveInventoryReader(rootDirectory: driveRoot) + let summary = try reader.syncStatus(path: nil) + let errorFiles = try reader.errorFiles(path: nil, limit: 1) + + #expect(summary.downloadedCount == 2) + #expect(summary.errorCount == 1) + #expect(errorFiles.count == 1) +} + +@Test func driveErrorResultsKeepResultLimitSeparateFromScanLimit() throws { + let root = try temporaryDriveInventoryRoot(named: "error-scan-limit") + defer { try? FileManager.default.removeItem(at: root) } + + let driveRoot = root.appendingPathComponent("Library/Mobile Documents") + try FileManager.default.createDirectory(at: driveRoot.appendingPathComponent("com~example~App"), withIntermediateDirectories: true) + try Data("ok".utf8).write(to: driveRoot.appendingPathComponent("com~example~App/.broken-one.txt.icloud")) + try Data("ok".utf8).write(to: driveRoot.appendingPathComponent("com~example~App/.broken-two.txt.icloud")) + try Data("ok".utf8).write(to: driveRoot.appendingPathComponent("com~example~App/.broken-three.txt.icloud")) + + let reader = ICloudDriveInventoryReader(rootDirectory: driveRoot) + let scannedErrorFiles = try reader.errorFiles(path: nil, limit: 10, scanLimit: 2) + let cappedErrorFiles = try reader.errorFiles(path: nil, limit: 1, scanLimit: 3) + + #expect(scannedErrorFiles.count == 2) + #expect(cappedErrorFiles.count == 1) +} + +@Test func driveSharedResultsUseBoundedTraversalBeforeFiltering() throws { + let root = try temporaryDriveInventoryRoot(named: "shared-scan-limit") + defer { try? FileManager.default.removeItem(at: root) } + + let driveRoot = root.appendingPathComponent("Library/Mobile Documents") + try FileManager.default.createDirectory(at: driveRoot.appendingPathComponent("com~example~App"), withIntermediateDirectories: true) + try Data("ok".utf8).write(to: driveRoot.appendingPathComponent("com~example~App/alpha.shared.txt")) + try Data("ok".utf8).write(to: driveRoot.appendingPathComponent("com~example~App/beta.shared.txt")) + try Data("ok".utf8).write(to: driveRoot.appendingPathComponent("com~example~App/gamma.shared.txt")) + + let reader = ICloudDriveInventoryReader(rootDirectory: driveRoot) + let scannedSharedItems = try reader.sharedItems(path: nil, limit: 10, scanLimit: 2) + let cappedSharedItems = try reader.sharedItems(path: nil, limit: 1, scanLimit: 3) + + #expect(scannedSharedItems.count == 2) + #expect(cappedSharedItems.count == 1) +} + @Test func scopesICloudDriveListToRelativePath() throws { let files = try ICloudDriveInventoryReader(rootDirectory: try mobileDocumentsFixtureURL()).listFiles(path: "com~example~Notes", depth: 1) @@ -33,4 +94,20 @@ private func mobileDocumentsFixtureURL() throws -> URL { #expect(containers.map(\.bundleId).contains("com~apple~CloudDocs")) #expect(containers.map(\.bundleId).contains("com~example~Notes")) + #expect(containers.allSatisfy { $0.sizeBytes == nil }) +} + +@Test func computesICloudDriveContainerStatsWhenSortingBySize() throws { + let containers = try ICloudDriveInventoryReader(rootDirectory: try mobileDocumentsFixtureURL()).listContainers(sortBy: .size) + + #expect(containers.contains { $0.bundleId == "com~apple~CloudDocs" && $0.sizeBytes != nil }) +} + +private func temporaryDriveInventoryRoot(named name: String) throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("icloud-cli-tests") + .appendingPathComponent(name) + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root } diff --git a/Tests/ICloudCLICoreTests/LocalInventoriesTests.swift b/Tests/ICloudCLICoreTests/LocalInventoriesTests.swift index 2472530..8537c5b 100644 --- a/Tests/ICloudCLICoreTests/LocalInventoriesTests.swift +++ b/Tests/ICloudCLICoreTests/LocalInventoriesTests.swift @@ -61,6 +61,26 @@ import Testing #expect(try reader.listPhotos().map(\.mediaType) == ["photo"]) } +@Test func limitsSyntheticPhotoInventoryWalks() throws { + let root = try temporaryDirectory(named: "media-limit") + defer { try? FileManager.default.removeItem(at: root) } + let photos = root.appendingPathComponent("Photos.photoslibrary/originals") + try FileManager.default.createDirectory(at: photos, withIntermediateDirectories: true) + try Data("one".utf8).write(to: photos.appendingPathComponent("IMG_0001.HEIC")) + try Data("two".utf8).write(to: photos.appendingPathComponent("IMG_0002.HEIC")) + + let reader = PhotosInventoryReader(photosLibraryDirectory: root.appendingPathComponent("Photos.photoslibrary")) + + #expect(try reader.listPhotos(limit: 1).count == 1) +} + +@Test func mapsSQLiteAuthorizationDeniedToPermissionError() { + let error = sqliteError(from: Data("Error: unable to open database \"/tmp/private.sqlite\": authorization denied".utf8), store: "/tmp/private.sqlite") + + #expect(error == .permissionDenied("/tmp/private.sqlite")) + #expect(error.localizedDescription.contains("Full Disk Access")) +} + @Test func readsSyntheticSQLiteInventoriesAndSensitiveGates() throws { let database = try syntheticInventoryDatabase() defer { try? FileManager.default.removeItem(at: database.deletingLastPathComponent()) } @@ -87,6 +107,35 @@ import Testing #expect(try reader.newsTopics().map(\.name) == ["Technology"]) } +@Test func readsAppleRemindersCoreDataSchema() throws { + let database = try appleRemindersFixtureDatabase() + defer { try? FileManager.default.removeItem(at: database.deletingLastPathComponent()) } + let reader = LocalSQLiteInventoryReader(database: database) + + let lists = try reader.reminderLists() + #expect(lists.first?.name == "Work") + #expect(lists.first?.itemCount == 1) + + let reminders = try reader.reminders(list: "Work", dueBefore: "2027-01-01T00:00:00Z", dueAfter: nil, includeCompleted: false) + #expect(reminders.map(\.title) == ["Ship PR"]) + #expect(reminders.first?.listName == "Work") + #expect(reminders.first?.isCompleted == false) +} + +@Test func readsAppleMapsCoreDataSchema() throws { + let database = try appleMapsFixtureDatabase() + defer { try? FileManager.default.removeItem(at: database.deletingLastPathComponent()) } + let reader = LocalSQLiteInventoryReader(database: database) + + let favorites = try reader.mapFavorites() + #expect(favorites.first?.name == "Home") + #expect(favorites.first?.address == "1 Example Way") + + let recents = try reader.mapRecents(limit: 1) + #expect(recents.map(\.name) == ["Coffee"]) + #expect(recents.first?.searchedAt == "2026-01-01T12:00:00Z") +} + @Test func readsAppleMessagesChatDatabaseSchema() throws { let database = try appleMessagesFixtureDatabase() defer { try? FileManager.default.removeItem(at: database.deletingLastPathComponent()) } @@ -108,6 +157,47 @@ import Testing #expect(recent.first?.body == nil) } +@Test func readsAppleMessagesRecentWithoutChatJoin() throws { + let root = try temporaryDirectory(named: "apple-messages-direct") + let database = root.appendingPathComponent("chat.db") + defer { try? FileManager.default.removeItem(at: root) } + let sql = """ + CREATE TABLE message (ROWID INTEGER PRIMARY KEY, handle_id INTEGER, date INTEGER, is_from_me INTEGER, text TEXT); + CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT); + CREATE TABLE chat (ROWID INTEGER PRIMARY KEY, guid TEXT, chat_identifier TEXT, display_name TEXT); + CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER); + INSERT INTO handle VALUES (1, 'alice@example.com'); + INSERT INTO message VALUES (1, 1, 771206400000000000, NULL, 'private body'); + """ + try runSQLite(database: database, sql: sql) + + let recent = try LocalSQLiteInventoryReader(database: database).recentMessages(confirmSensitive: true, includeBody: false, since: nil, limit: 10) + + #expect(recent.map(\.chatIdentifier) == ["alice@example.com"]) + #expect(recent.first?.isFromMe == false) + #expect(recent.first?.body == nil) +} + +@Test func readsAppleSafariHistorySchema() throws { + let root = try temporaryDirectory(named: "apple-safari-history") + let database = root.appendingPathComponent("History.db") + defer { try? FileManager.default.removeItem(at: root) } + let sql = """ + CREATE TABLE history_items (id INTEGER PRIMARY KEY, url TEXT, visit_count INTEGER); + CREATE TABLE history_visits (id INTEGER PRIMARY KEY, history_item INTEGER, visit_time REAL, title TEXT, load_successful INTEGER); + INSERT INTO history_items VALUES (1, 'https://example.com/private/path', 3); + INSERT INTO history_visits VALUES (1, 1, 788961600, 'Example', 1); + """ + try runSQLite(database: database, sql: sql) + + let history = try LocalSQLiteInventoryReader(database: database).safariHistory(confirmSensitive: true, since: "2026-01-01T00:00:00Z", until: nil, limit: 10, redactURLs: true) + + #expect(history.map(\.url) == ["https://example.com"]) + #expect(history.first?.title == "Example") + #expect(history.first?.visitedAt == "2026-01-01T12:00:00Z") + #expect(history.first?.visitCount == 3) +} + @Test func limitsMessageConversations() throws { let database = try messageConversationsFixtureDatabase() defer { try? FileManager.default.removeItem(at: database.deletingLastPathComponent()) } @@ -201,6 +291,66 @@ private func appleMessagesFixtureDatabase() throws -> URL { return database } +private func appleRemindersFixtureDatabase() throws -> URL { + let root = try temporaryDirectory(named: "apple-reminders") + let database = root.appendingPathComponent("Data-example.sqlite") + let sql = """ + CREATE TABLE ZREMCDBASELIST ( + Z_PK INTEGER PRIMARY KEY, + ZMARKEDFORDELETION INTEGER, + ZNAME TEXT + ); + CREATE TABLE ZREMCDREMINDER ( + Z_PK INTEGER PRIMARY KEY, + ZMARKEDFORDELETION INTEGER, + ZCOMPLETED INTEGER, + ZFLAGGED INTEGER, + ZPRIORITY INTEGER, + ZLIST INTEGER, + ZTITLE TEXT, + ZNOTES TEXT, + ZDUEDATE REAL, + ZCREATIONDATE REAL + ); + INSERT INTO ZREMCDBASELIST VALUES (1, 0, 'Work'); + INSERT INTO ZREMCDREMINDER VALUES (1, 0, 0, 1, 5, 1, 'Ship PR', 'Review', 788961600, 788875200); + """ + try runSQLite(database: database, sql: sql) + return database +} + +private func appleMapsFixtureDatabase() throws -> URL { + let root = try temporaryDirectory(named: "apple-maps") + let database = root.appendingPathComponent("MapsSync_0.0.1") + let sql = """ + CREATE TABLE ZFAVORITEITEM ( + Z_PK INTEGER PRIMARY KEY, + ZHIDDEN INTEGER, + ZPOSITIONINDEX INTEGER, + ZLATITUDE REAL, + ZLONGITUDE REAL, + ZCUSTOMNAME TEXT, + ZMAPITEMNAME TEXT, + ZMAPITEMADDRESS TEXT, + ZMAPITEMCATEGORY TEXT + ); + CREATE TABLE ZHISTORYITEM ( + Z_PK INTEGER PRIMARY KEY, + ZPOSITIONINDEX INTEGER, + ZLATITUDE REAL, + ZLONGITUDE REAL, + ZCUSTOMNAME TEXT, + ZLOCATIONDISPLAY TEXT, + ZQUERY TEXT, + ZMODIFICATIONTIME REAL + ); + INSERT INTO ZFAVORITEITEM VALUES (1, 0, 1, 1.0, 2.0, 'Home', 'Example Home', '1 Example Way', 'home'); + INSERT INTO ZHISTORYITEM VALUES (1, 1, 3.0, 4.0, 'Coffee', '2 Example Way', 'coffee', 788961600); + """ + try runSQLite(database: database, sql: sql) + return database +} + private func messageConversationsFixtureDatabase() throws -> URL { let root = try temporaryDirectory(named: "message-conversations") let database = root.appendingPathComponent("inventory.sqlite") diff --git a/Tests/ICloudCLICoreTests/SafariBookmarksReaderTests.swift b/Tests/ICloudCLICoreTests/SafariBookmarksReaderTests.swift index 4ca172a..f12ac51 100644 --- a/Tests/ICloudCLICoreTests/SafariBookmarksReaderTests.swift +++ b/Tests/ICloudCLICoreTests/SafariBookmarksReaderTests.swift @@ -16,6 +16,12 @@ import Testing ]) } +@Test func safariBookmarksPermissionErrorsDescribeFullDiskAccess() { + let error = SafariBookmarksError.permissionDenied("/tmp/Bookmarks.plist") + + #expect(error.localizedDescription.contains("Full Disk Access")) +} + @Test func readsSafariReadingListFixture() throws { let fixtureURL = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() @@ -48,6 +54,12 @@ import Testing ]) } +@Test func safariFrequentlyVisitedPermissionErrorsDescribeFullDiskAccess() { + let error = SafariFrequentlyVisitedError.permissionDenied("/tmp/TopSites.plist") + + #expect(error.localizedDescription.contains("Full Disk Access")) +} + @Test func rendersSafariFrequentlyVisitedText() throws { let rendered = try CommandRunner().render( [SafariFrequentlyVisitedSite(title: "Dashboard", url: "https://example.com/dashboard", rank: 1)], diff --git a/Tests/ICloudCLICoreTests/SafariTabsReaderTests.swift b/Tests/ICloudCLICoreTests/SafariTabsReaderTests.swift index 12cfe52..742fbcc 100644 --- a/Tests/ICloudCLICoreTests/SafariTabsReaderTests.swift +++ b/Tests/ICloudCLICoreTests/SafariTabsReaderTests.swift @@ -158,6 +158,12 @@ import Testing } } +@Test func safariPermissionErrorsDescribeFullDiskAccess() { + let error = SafariTabsError.permissionDenied(["/tmp/CurrentSession.plist"]) + + #expect(error.localizedDescription.contains("Full Disk Access")) +} + @Test func textRenderingIncludesTitleWhenPresent() throws { let rendered = try CommandRunner().render( [ diff --git a/scripts/live-command-audit.py b/scripts/live-command-audit.py new file mode 100755 index 0000000..6fa7112 --- /dev/null +++ b/scripts/live-command-audit.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Run a privacy-preserving live audit of the icloud-cli command surface. + +The script executes each command against the current Mac and prints only status, +exit code, shape/count, and short error messages. It intentionally does not +print command payloads because many commands touch local private metadata. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any + + +def command_catalog(cache_dir: str, tag: str) -> list[tuple[str, list[str]]]: + return [ + ("snapshot", ["snapshot", "--format", "json"]), + ("account status", ["account", "status", "--format", "json"]), + ("backup status", ["backup", "status", "--format", "json"]), + ("family status", ["family", "status", "--format", "json"]), + ("storage status", ["storage", "status", "--format", "json"]), + ("focus status", ["focus", "status", "--format", "json"]), + ("devices list", ["devices", "list", "--format", "json"]), + ("wallet passes", ["wallet", "passes", "--format", "json"]), + ("handoff list", ["handoff", "list", "--limit", "10", "--format", "json"]), + ("drive list", ["drive", "list", "--depth", "1", "--format", "json"]), + ("drive containers", ["drive", "containers", "--sort-by", "name", "--format", "json"]), + ("drive status", ["drive", "status", "--limit", "25", "--format", "json"]), + ("drive errors", ["drive", "errors", "--limit", "25", "--format", "json"]), + ("drive shared", ["drive", "shared", "--limit", "25", "--format", "json"]), + ("drive recents", ["drive", "recents", "--limit", "25", "--format", "json"]), + ("shortcuts list", ["shortcuts", "list", "--format", "json"]), + ("photos screenshots", ["photos", "screenshots", "--format", "json"]), + ("photos list", ["photos", "list", "--limit", "25", "--format", "json"]), + ("photos shared-albums", ["photos", "shared-albums", "--format", "json"]), + ("photos shared-library", ["photos", "shared-library", "--format", "json"]), + ("notes list", ["notes", "list", "--format", "json"]), + ("notes accounts", ["notes", "accounts", "--format", "json"]), + ("notes folders", ["notes", "folders", "--format", "json"]), + ("notes tags", ["notes", "tags", "--format", "json"]), + ("notes shared", ["notes", "shared", "--format", "json"]), + ("reminders lists", ["reminders", "lists", "--format", "json"]), + ("reminders list", ["reminders", "list", "--format", "json"]), + ("reminders flagged", ["reminders", "flagged", "--limit", "25", "--format", "json"]), + ("reminders today", ["reminders", "today", "--limit", "25", "--format", "json"]), + ("reminders scheduled", ["reminders", "scheduled", "--limit", "25", "--format", "json"]), + ("reminders assigned", ["reminders", "assigned", "--limit", "25", "--format", "json"]), + ("calendar accounts", ["calendar", "accounts", "--format", "json"]), + ("calendar list", ["calendar", "list", "--format", "json"]), + ("calendar events", ["calendar", "events", "--limit", "25", "--format", "json"]), + ("contacts list", ["contacts", "list", "--limit", "25", "--format", "json"]), + ("findmy devices", ["findmy", "devices", "--format", "json"]), + ("findmy people", ["findmy", "people", "--format", "json"]), + ("mail accounts", ["mail", "accounts", "--format", "json"]), + ("mail mailboxes", ["mail", "mailboxes", "--format", "json"]), + ("mail recent", ["mail", "recent", "--confirm-sensitive", "--limit", "10", "--format", "json"]), + ("messages conversations", ["messages", "conversations", "--limit", "25", "--format", "json"]), + ("messages recent", ["messages", "recent", "--confirm-sensitive", "--limit", "10", "--format", "json"]), + ("maps favorites", ["maps", "favorites", "--format", "json"]), + ("maps recents", ["maps", "recents", "--limit", "25", "--format", "json"]), + ("news history", ["news", "history", "--limit", "25", "--format", "json"]), + ("news topics", ["news", "topics", "--format", "json"]), + ("books collections", ["books", "collections", "--format", "json"]), + ("books list", ["books", "list", "--limit", "25", "--format", "json"]), + ("voice-memos list", ["voice-memos", "list", "--limit", "25", "--format", "json"]), + ("home homes", ["home", "homes", "--format", "json"]), + ("home rooms", ["home", "rooms", "--format", "json"]), + ("home accessories", ["home", "accessories", "--format", "json"]), + ("home scenes", ["home", "scenes", "--format", "json"]), + ("health summary", ["health", "summary", "--confirm-sensitive", "--format", "json"]), + ("freeform list", ["freeform", "list", "--limit", "25", "--format", "json"]), + ("music status", ["music", "status", "--format", "json"]), + ("music playlists", ["music", "playlists", "--limit", "25", "--format", "json"]), + ("music tracks", ["music", "tracks", "--limit", "25", "--format", "json"]), + ("stocks watchlist", ["stocks", "watchlist", "--format", "json"]), + ("stocks groups", ["stocks", "groups", "--format", "json"]), + ("weather favorites", ["weather", "favorites", "--format", "json"]), + ("tags list", ["tags", "list", "--format", "json"]), + ("tags items", ["tags", "items", "--tag", tag, "--limit", "25", "--format", "json"]), + ("permissions doctor", ["permissions", "doctor", "--format", "json"]), + ("safari tabs", ["safari", "tabs", "--source", "all", "--format", "json"]), + ("safari history", ["safari", "history", "--confirm-sensitive", "--limit", "10", "--redact-urls", "--format", "json"]), + ("safari bookmarks", ["safari", "bookmarks", "--format", "json"]), + ("safari reading-list", ["safari", "reading-list", "--format", "json"]), + ("safari frequently-visited", ["safari", "frequently-visited", "--limit", "10", "--format", "json"]), + ("safari cloud-tabs probe", ["safari", "cloud-tabs", "probe", "--format", "json"]), + ("safari cloud-tabs list", ["safari", "cloud-tabs", "list", "--confirm-sensitive", "--format", "json"]), + ("safari profiles list", ["safari", "profiles", "list", "--format", "json"]), + ("safari extensions list", ["safari", "extensions", "list", "--format", "json"]), + ("cache status", ["cache", "status", "--format", "json", "--output-dir", cache_dir]), + ("watch once", ["watch", "--once", "--output-dir", cache_dir]), + ("cache read drive-list", ["cache", "read", "drive-list", "--format", "json", "--output-dir", cache_dir]), + ] + + +def payload_shape(payload: Any) -> tuple[int, str, str]: + if isinstance(payload, list): + return len(payload), "array", "" + if isinstance(payload, dict): + for key in ( + "items", + "files", + "containers", + "passes", + "activities", + "photos", + "screenshots", + "notes", + "reminders", + "contacts", + "conversations", + "messages", + "favorites", + "recents", + "history", + "topics", + "collections", + "books", + "recordings", + "homes", + "rooms", + "accessories", + "scenes", + "boards", + "playlists", + "tracks", + "watchlist", + "groups", + "locations", + "tags", + "devices", + "people", + "accounts", + "mailboxes", + "events", + "calendars", + "errors", + "sharedItems", + ): + value = payload.get(key) + if isinstance(value, list): + return len(value), f"{key}[]", scalar_note(payload) + return len(payload), "object-keys", scalar_note(payload) + return 0, type(payload).__name__, "" + + +def scalar_note(payload: dict[str, Any]) -> str: + for key in ("status", "state", "message", "error", "signedIn", "available", "readable"): + value = payload.get(key) + if value is not None and not isinstance(value, (list, dict)): + return f"{key}={value}"[:120] + return "" + + +def classify(exe: Path, args: list[str], timeout: int) -> tuple[str, str, int, str, str]: + try: + process = subprocess.run( + [str(exe), *args], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout, + ) + except subprocess.TimeoutExpired as error: + stderr = error.stderr if isinstance(error.stderr, str) else "" + return "TIMEOUT", "-", 0, "-", stderr.replace("\n", " ")[:220] + + stdout = process.stdout.strip() + stderr = process.stderr.strip() + if process.returncode != 0: + return "FAIL", str(process.returncode), 0, "-", (stderr or stdout).replace("\n", " ")[:220] + if not stdout: + return "EMPTY", "0", 0, "-", "no stdout" + + try: + count, shape, note = payload_shape(json.loads(stdout)) + status = "OK_DATA" if count > 0 else "OK_EMPTY" + return status, "0", count, shape, note + except json.JSONDecodeError: + return "OK_TEXT", "0", len(stdout), "text", stdout.replace("\n", " ")[:220] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", default=".build/debug/icloud-cli", help="Path to the icloud-cli binary") + parser.add_argument("--timeout", type=int, default=18, help="Per-command timeout in seconds") + parser.add_argument("--tag", default="Red", help="Finder tag name to use for the tags items probe") + args = parser.parse_args() + + exe = Path(args.binary).expanduser().resolve() + if not exe.exists(): + print(f"error: binary not found: {exe}", file=sys.stderr) + return 2 + + with tempfile.TemporaryDirectory(prefix="icloud-cli-live-cache-") as cache_dir: + print("command\tstatus\texit\tcount\tshape\tnote") + for name, command_args in command_catalog(cache_dir, args.tag): + status, exit_code, count, shape, note = classify(exe, command_args, args.timeout) + print(f"{name}\t{status}\t{exit_code}\t{count}\t{shape}\t{note}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())