Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions Sources/ICloudCLICore/AppleMetadataInventories.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ public struct LocalMetadataStoreReader: Sendable {
if command == .safariCloudTabsList {
return try cloudTabRows(options: options)
}
if command == .mailAccounts || command == .mailMailboxes || command == .mailRecent {
return try mailRows(for: command, options: options)
}
if command == .musicStatus || command == .musicPlaylists || command == .musicTracks {
try validateSQLiteStore(featureName: "Music library")
}
guard let tableName = command.tableName else {
throw LocalInventoryError.sqliteFailure("No metadata table configured for \(command.displayName)")
}
Expand Down Expand Up @@ -118,6 +124,126 @@ public struct LocalMetadataStoreReader: Sendable {
.map { redact(row: $0, command: .safariCloudTabsList, options: options) }
}

private func mailRows(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) }
}
let hasAppleMailboxes = try tableExists("mailboxes")
let hasAppleMessages = try tableExists("messages")
guard hasAppleMailboxes || hasAppleMessages else {
throw LocalInventoryError.unsupportedSchema(store: database.path, detail: "missing mail metadata tables or Apple Mail Envelope Index tables")
}
switch command {
case .mailAccounts:
return try appleMailAccounts(options: options)
case .mailMailboxes:
return try appleMailMailboxes(options: options)
case .mailRecent:
return try appleMailRecent(options: options)
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")
}
let mailboxColumns = try columns(in: "mailboxes")
let displayName = coalesceExpression(
candidates: ["account_identifier", "account_url", "url", "name"],
columns: mailboxColumns,
alias: "displayName",
fallback: "'Local Mail'"
)
let accountLocator = coalesceRawExpression(candidates: ["account_url", "url"], columns: mailboxColumns, fallback: "''")
let sql = """
SELECT
\(displayName),
CASE
WHEN \(accountLocator) LIKE '%icloud%' THEN 'iCloud'
WHEN \(accountLocator) LIKE 'imap://%' THEN 'IMAP'
WHEN \(accountLocator) LIKE 'pop://%' THEN 'POP'
ELSE NULL
END AS type,
COUNT(*) AS mailboxCount
FROM mailboxes
GROUP BY displayName, type
ORDER BY displayName ASC
LIMIT \(bounded(options.limit, defaultValue: 50, max: 1000));
"""
return try query(sql).map { MetadataRow(kind: MetadataCommand.mailAccounts.displayName, fields: $0) }
}

private func appleMailMailboxes(options: MetadataOptions) throws -> [MetadataRow] {
guard try tableExists("mailboxes") else {
throw LocalInventoryError.unsupportedSchema(store: database.path, detail: "missing Apple Mail mailboxes table")
}
let mailboxColumns = try columns(in: "mailboxes")
let messageColumns = try tableExists("messages") ? columns(in: "messages") : []
let account = coalesceRawExpression(candidates: ["account_identifier", "account_url", "url"], columns: mailboxColumns, tableAlias: "m", fallback: "'Local Mail'")
let mailbox = coalesceRawExpression(candidates: ["name", "url"], columns: mailboxColumns, tableAlias: "m", fallback: "CAST(m.ROWID AS TEXT)")
let hasMessages = !messageColumns.isEmpty && messageColumns.contains("mailbox")
let total = coalesceRawExpression(candidates: ["total_count", "messages_count"], columns: mailboxColumns, tableAlias: "m", fallback: hasMessages ? "COUNT(msg.ROWID)" : "0")
let unread = coalesceRawExpression(candidates: ["unread_count"], columns: mailboxColumns, tableAlias: "m", fallback: hasMessages && messageColumns.contains("read") ? "SUM(CASE WHEN COALESCE(msg.read, 0) = 0 THEN 1 ELSE 0 END)" : "0")
let joinMessages = hasMessages ? "LEFT JOIN messages msg ON msg.mailbox = m.ROWID" : ""
let accountFilter = options.account.map { " AND \(account) = '\(sqlEscape($0))'" } ?? ""
let sql = """
SELECT
\(account) AS account,
\(mailbox) AS mailbox,
\(total) AS totalCount,
\(unread) AS unreadCount
FROM mailboxes m
\(joinMessages)
WHERE 1 = 1\(accountFilter)
GROUP BY m.ROWID
ORDER BY account ASC, mailbox ASC
LIMIT \(bounded(options.limit, defaultValue: 50, max: 1000));
"""
return try query(sql).map { MetadataRow(kind: MetadataCommand.mailMailboxes.displayName, fields: $0) }
}

private func appleMailRecent(options: MetadataOptions) throws -> [MetadataRow] {
guard try tableExists("messages"), try tableExists("mailboxes") else {
throw LocalInventoryError.unsupportedSchema(store: database.path, detail: "missing Apple Mail messages or mailboxes table")
}
let mailboxColumns = try columns(in: "mailboxes")
let messageColumns = try columns(in: "messages")
let account = coalesceRawExpression(candidates: ["account_identifier", "account_url", "url"], columns: mailboxColumns, tableAlias: "mb", fallback: "'Local Mail'")
let mailboxFallback = messageColumns.contains("mailbox") ? "CAST(msg.mailbox AS TEXT)" : "NULL"
let mailbox = coalesceRawExpression(candidates: ["name", "url"], columns: mailboxColumns, tableAlias: "mb", fallback: mailboxFallback)
let accountFilter = options.account.map { " AND \(account) = '\(sqlEscape($0))'" } ?? ""
let mailboxFilter = options.mailbox.map { " AND \(mailbox) = '\(sqlEscape($0))'" } ?? ""
let hasAddresses = try tableExists("addresses") && messageColumns.contains("sender")
let addressColumns = hasAddresses ? try columns(in: "addresses") : []
let senderExpression = hasAddresses ? coalesceRawExpression(candidates: ["address", "comment"], columns: addressColumns, tableAlias: "a", fallback: "CAST(msg.sender AS TEXT)") : (messageColumns.contains("sender") ? "CAST(msg.sender AS TEXT)" : "NULL")
let senderJoin = hasAddresses ? "LEFT JOIN addresses a ON a.ROWID = msg.sender" : ""
let hasSubjects = try tableExists("subjects") && messageColumns.contains("subject")
let subjectColumns = hasSubjects ? try columns(in: "subjects") : []
let subjectExpression = hasSubjects ? coalesceRawExpression(candidates: ["subject"], columns: subjectColumns, tableAlias: "s", fallback: "CAST(msg.subject AS TEXT)") : (messageColumns.contains("subject") ? "CAST(msg.subject AS TEXT)" : "NULL")
let subjectJoin = hasSubjects ? "LEFT JOIN subjects s ON s.ROWID = msg.subject" : ""
let dateExpression = coalesceRawExpression(candidates: ["date_sent", "date_received", "date_last_viewed"], columns: messageColumns, tableAlias: "msg", fallback: "0")
let mailboxJoin = messageColumns.contains("mailbox") ? "LEFT JOIN mailboxes mb ON mb.ROWID = msg.mailbox" : "LEFT JOIN mailboxes mb ON 0"
let sql = """
SELECT
\(senderExpression) AS sender,
\(subjectExpression) AS subject,
datetime(\(dateExpression), 'unixepoch') AS sentAt,
\(mailbox) AS mailbox
FROM messages msg
\(mailboxJoin)
\(senderJoin)
\(subjectJoin)
WHERE 1 = 1\(accountFilter)\(mailboxFilter)
ORDER BY \(dateExpression) DESC
LIMIT \(bounded(options.limit, defaultValue: 50, max: 1000));
"""
return try query(sql).map { MetadataRow(kind: MetadataCommand.mailRecent.displayName, fields: $0) }
}

public static func defaultStore(for command: MetadataCommand) -> URL {
let home = FileManager.default.homeDirectoryForCurrentUser
switch command {
Expand Down Expand Up @@ -180,6 +306,26 @@ public struct LocalMetadataStoreReader: Sendable {
return try JSONDecoder().decode([[String: MetadataValue]].self, from: data)
}

private func tableExists(_ table: String) throws -> Bool {
guard !table.isEmpty else { return false }
let rows = try query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = '\(sqlEscape(table))' LIMIT 1;")
return !rows.isEmpty
}

private func columns(in table: String) throws -> Set<String> {
let rows = try query("PRAGMA table_info(\(table));")
return Set(rows.compactMap { $0["name"]?.stringValue })
}

private func validateSQLiteStore(featureName: String) throws {
guard FileManager.default.fileExists(atPath: database.path) else { throw LocalInventoryError.missingStore(database.path) }
do {
_ = try query("PRAGMA schema_version;")
} catch {
throw LocalInventoryError.unsupportedSchema(store: database.path, detail: "\(featureName) store is not a readable SQLite database for this command")
}
}

private func requireConfirmationIfNeeded(command: MetadataCommand, options: MetadataOptions) throws {
let sensitive: [MetadataCommand: String] = [
.healthSummary: "icloud-cli health summary",
Expand Down Expand Up @@ -566,6 +712,16 @@ private func sqlEscape(_ value: String) -> String {
value.replacingOccurrences(of: "'", with: "''")
}

private func coalesceExpression(candidates: [String], columns: Set<String>, alias: String, fallback: String) -> String {
"\(coalesceRawExpression(candidates: candidates, columns: columns, fallback: fallback)) AS \(alias)"
}

private func coalesceRawExpression(candidates: [String], columns: Set<String>, tableAlias: String? = nil, fallback: String) -> String {
let prefix = tableAlias.map { "\($0)." } ?? ""
let available = candidates.filter { columns.contains($0) }.map { "\(prefix)\($0)" }
return "COALESCE(\((available + [fallback]).joined(separator: ", ")))"
}

private func redactURL(_ raw: String) -> String {
guard let components = URLComponents(string: raw), let scheme = components.scheme, let host = components.host else {
return raw
Expand Down
47 changes: 47 additions & 0 deletions Tests/ICloudCLICoreTests/BroadIssueInventoryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,53 @@ import Testing
#expect(health.first?.int("recordCount") == 42)
}

@Test func readsAppleMailEnvelopeIndexShape() throws {
let root = try temporaryDirectoryForBroadIssues(named: "mail-envelope")
let database = root.appendingPathComponent("Envelope Index")
defer { try? FileManager.default.removeItem(at: root) }
let sql = """
CREATE TABLE mailboxes (ROWID INTEGER PRIMARY KEY, url TEXT, name TEXT, account_url TEXT, account_identifier TEXT, total_count INTEGER, messages_count INTEGER, unread_count INTEGER);
CREATE TABLE addresses (ROWID INTEGER PRIMARY KEY, address TEXT, comment TEXT);
CREATE TABLE subjects (ROWID INTEGER PRIMARY KEY, subject TEXT);
CREATE TABLE messages (ROWID INTEGER PRIMARY KEY, sender INTEGER, subject INTEGER, mailbox INTEGER, date_sent INTEGER, date_received INTEGER, date_last_viewed INTEGER, read INTEGER);
INSERT INTO mailboxes VALUES (1, 'imap://operator@example.com/INBOX', 'Inbox', 'imap://operator@example.com', 'iCloud Mail', 2, 2, 1);
INSERT INTO addresses VALUES (1, 'sender@example.com', 'Sender');
INSERT INTO subjects VALUES (1, 'Hello');
INSERT INTO messages VALUES (1, 1, 1, 1, 1770000000, 1770000001, 1770000002, 0);
"""
try runSQLiteForBroadIssues(database: database, sql: sql)

let reader = LocalMetadataStoreReader(database: database)
let accounts = try reader.rows(for: .mailAccounts)
#expect(accounts.first?.string("displayName") == "iCloud Mail")
#expect(accounts.first?.string("type") == "IMAP")

let mailboxes = try reader.rows(for: .mailMailboxes, options: MetadataOptions(account: "iCloud Mail"))
#expect(mailboxes.first?.string("mailbox") == "Inbox")
#expect(mailboxes.first?.int("unreadCount") == 1)

let recent = try reader.rows(for: .mailRecent, options: MetadataOptions(mailbox: "Inbox", confirmSensitive: true))
#expect(recent.first?.string("sender") == "sender@example.com")
#expect(recent.first?.string("subject") == "Hello")
}

@Test func reportsUnsupportedMusicStoreWhenNotSQLite() throws {
let root = try temporaryDirectoryForBroadIssues(named: "music-nonsqlite")
let database = root.appendingPathComponent("Library.musicdb")
defer { try? FileManager.default.removeItem(at: root) }
try "not sqlite".write(to: database, atomically: true, encoding: .utf8)

do {
_ = try LocalMetadataStoreReader(database: database).rows(for: .musicStatus)
Issue.record("Expected unsupported schema")
} catch LocalInventoryError.unsupportedSchema(let store, let detail) {
#expect(store == database.path)
#expect(detail.contains("Music library store is not a readable SQLite database"))
} catch {
Issue.record("Expected unsupported schema, got \(error)")
}
}

@Test func readsSyntheticAccountFamilyAndBackupPlists() throws {
let plist = try syntheticAccountPlist()
defer { try? FileManager.default.removeItem(at: plist.deletingLastPathComponent()) }
Expand Down
Loading