Skip to content
Open
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
240 changes: 156 additions & 84 deletions Sources/mas/Models/InstalledApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,10 @@
// Copyright © 2018 mas-cli. All rights reserved.
//

private import Atomics
private import CoreFoundation
private import Foundation
internal import JSONAST
private import JSONParsing
private import ObjectiveC

struct InstalledApp {
let adamID: ADAMID
Expand All @@ -31,29 +29,22 @@
adamID == 0
}

fileprivate init(for item: NSMetadataItem, withFullJSON: Bool) {
let valueByAttribute = item.values(
forAttributes: withFullJSON
? item.attributes + [NSMetadataItemPathKey] // swiftformat:disable:this indent
: [
"kMDItemAppStoreAdamID",
NSMetadataItemCFBundleIdentifierKey,
"_kMDItemDisplayNameWithExtensions",
NSMetadataItemPathKey,
NSMetadataItemVersionKey,
],
)
?? .init() // swiftformat:disable:this indent
adamID = valueByAttribute["kMDItemAppStoreAdamID"] as? ADAMID ?? 0
bundleID = .init(describing: valueByAttribute[NSMetadataItemCFBundleIdentifierKey] ?? "")
name = .init(describing: valueByAttribute["_kMDItemDisplayNameWithExtensions"] ?? "").removingSuffix(".app")
path = valueByAttribute[NSMetadataItemPathKey].map { pathAny in
let path = String(describing: pathAny)
return (try? URL(folderPath: path).resourceValues(forKeys: [.canonicalPathKey]))?.canonicalPath ?? path
}
?? "" // swiftformat:disable:this indent
version = .init(describing: valueByAttribute[NSMetadataItemVersionKey] ?? "")
init(adamID: ADAMID, bundleID: String, name: String, path: String, version: String) {
self.adamID = adamID
self.bundleID = bundleID
self.name = name
self.path = path
self.version = version

// Build the same attribute map the Spotlight-backed init produced, using the kMDItem* keys
// so `JSON.Key.normalized` maps them to adamID/bundleID/path/version; the tail (sort + inject
// "name") is identical to the original so `--json` output stays well-formed.
let valueByAttribute: [String: Any] = [
"kMDItemAppStoreAdamID": adamID,
NSMetadataItemCFBundleIdentifierKey: bundleID,
NSMetadataItemPathKey: path,
NSMetadataItemVersionKey: version,
]
jsonObjectRaw = .init(valueByAttribute.map { (.init(rawValue: $0.key), .init(for: $0.value)) })
let jsonObjectRaw = jsonObjectRaw
let name = name
Expand Down Expand Up @@ -281,86 +272,167 @@
}
}

func installedApps(withFullJSON: Bool = false) async throws -> [InstalledApp] {

Check warning on line 275 in Sources/mas/Models/InstalledApp.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused function parameter "withFullJSON" or name it "_".

See more on https://sonarcloud.io/project/issues?id=mas-cli_mas&issues=AZ68MVvzXoZfzzBnbd70&open=AZ68MVvzXoZfzzBnbd70&pullRequest=1266
try await installedApps(matching: "kMDItemAppStoreAdamID LIKE '*'", withFullJSON: withFullJSON)
installedAppsFromReceipts()
}

func installedApps(withADAMID adamID: ADAMID, withFullJSON: Bool = false) async throws -> [InstalledApp] {

Check warning on line 279 in Sources/mas/Models/InstalledApp.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused function parameter "withFullJSON" or name it "_".

See more on https://sonarcloud.io/project/issues?id=mas-cli_mas&issues=AZ68MVvzXoZfzzBnbd71&open=AZ68MVvzXoZfzzBnbd71&pullRequest=1266
try await installedApps(matching: "kMDItemAppStoreAdamID = \(adamID)", withFullJSON: withFullJSON)
installedAppsFromReceipts().filter { $0.adamID == adamID }
}
Comment on lines 275 to 281

@MainActor
func installedApps(matching metadataQuery: String, withFullJSON: Bool = false) async throws -> [InstalledApp] {
var observer = (any NSObjectProtocol)?.none
defer {
if let observer {
NotificationCenter.default.removeObserver(observer)
// macOS 26 (Tahoe) no longer indexes the kMDItemAppStore* Spotlight attributes (kMDItemAppStoreAdamID
// is null even for installed apps), so the previous NSMetadataQuery-based discovery returned nothing.
Comment on lines +283 to +284
// Enumerate App Store apps from each app's Contents/_MASReceipt/receipt — written by the App Store at
// install time — which is authoritative and Spotlight-independent.
private func installedAppsFromReceipts() -> [InstalledApp] {
applicationsFolderURLs
.flatMap(\.installedAppURLs) // swiftformat:disable:this indent
.compactMap { appURL in
guard
let receipt = try? Data(
contentsOf: appURL.appending(path: "Contents/_MASReceipt/receipt", directoryHint: .notDirectory),
)
else {
return InstalledApp?.none
}
Comment on lines +287 to 297

let attributes = receiptAttributes([UInt8](receipt))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since we can parse the receipt directly as Data without copying it to a [UInt8] array, we should pass receipt directly to receiptAttributes.

Suggested change
let attributes = receiptAttributes([UInt8](receipt))
let attributes = receiptAttributes(receipt)

return InstalledApp(
adamID: attributes[masReceiptAdamIDType].flatMap(adamID(fromReceiptValue:)) ?? 0,
bundleID: attributes[masReceiptBundleIDType].flatMap(string(fromReceiptValue:)) ?? "",
name: appURL.deletingPathExtension().lastPathComponent,
path: appURL.filePath,
version: attributes[masReceiptVersionType].flatMap(string(fromReceiptValue:)) ?? "",
)
}
.sorted(using: KeyPathComparator(\.name, comparator: .localizedStandard))
}

// ─── Mac App Store receipt (DER PKCS#7) attribute reader ───
//
// The receipt's eContent is a SET of attributes, each:
// SEQUENCE { type INTEGER, version INTEGER, value OCTET STRING }
// where the value wraps a single DER element:
// type 1 (adamID) → INTEGER, type 2 (bundleID) → UTF8String, type 3 (version) → UTF8String.
// See: https://developer.apple.com/library/archive/releasenotes/General/ValidateAppStoreReceipt

private let masReceiptAdamIDType = 1
private let masReceiptBundleIDType = 2
private let masReceiptVersionType = 3

let query = NSMetadataQuery()
query.predicate = .init(format: metadataQuery)
query.searchScopes = applicationsFolderURLs
// Walk the DER tree collecting receipt attributes as `type -> raw OCTET STRING value bytes`.
private func receiptAttributes(_ bytes: [UInt8]) -> [Int: [UInt8]] {
var attributes: [Int: [UInt8]] = [:]

return try await withCheckedThrowingContinuation { continuation in
let alreadyResumed = ManagedAtomic(false)
observer = NotificationCenter.default.addObserver(
forName: .NSMetadataQueryDidFinishGathering,
object: query,
queue: nil,
) { notification in
guard !alreadyResumed.exchange(true, ordering: .acquiringAndReleasing) else {
return
func attribute(_ sequence: [UInt8]) -> (type: Int, value: [UInt8])? {
var index = 0
guard let typeElement = DER.element(sequence, &index), typeElement.tag == DER.integer else {
return nil
}
guard let versionElement = DER.element(sequence, &index), versionElement.tag == DER.integer else {
return nil
}
guard let valueElement = DER.element(sequence, &index), valueElement.tag == DER.octetString else {
return nil
}
guard index == sequence.count else {
return nil // exactly three elements
}
_ = versionElement
return (Int(DER.unsignedInteger(typeElement.content)), valueElement.content)
}

func walk(_ bytes: [UInt8]) {
var index = 0
while index < bytes.count {
let start = index
guard let node = DER.element(bytes, &index) else {
break
}
guard let query = notification.object as? NSMetadataQuery else {
continuation.resume(
throwing: MASError.error(
"Notification Center returned a \(type(of: notification.object)) instead of a NSMetadataQuery",
),
)
return
if node.tag == DER.sequence, let attribute = attribute(node.content) {
attributes[attribute.type] = attribute.value
}
if node.tag & DER.constructed != 0 {
walk(node.content) // descend into SEQUENCE / SET / context-tagged nodes
} else if node.tag == DER.octetString {
walk(node.content) // the eContent payload is wrapped in an OCTET STRING
}
if index <= start {
break
}
}
}
Comment on lines +345 to +364

query.stop()

let installedApps = query.results
.compactMap { ($0 as? NSMetadataItem).map { InstalledApp(for: $0, withFullJSON: withFullJSON) } }
.sorted(using: KeyPathComparator(\.name, comparator: .localizedStandard))
walk(bytes)
return attributes
}

if !["1", "true", "yes"].contains(ProcessInfo.processInfo.environment["MAS_NO_AUTO_INDEX"]?.lowercased()) {
let installedAppPathSet = Set(installedApps.map(\.path))
for installedAppURL in applicationsFolderURLs.flatMap(\.installedAppURLs)
where !installedAppPathSet.contains(installedAppURL.filePath) { // swiftformat:disable:this indent
MAS.printer.warning(
"Found a likely App Store app that is not indexed in Spotlight in ",
installedAppURL.filePath,
"""
private func adamID(fromReceiptValue value: [UInt8]) -> ADAMID? {
var index = 0
guard let inner = DER.element(value, &index), inner.tag == DER.integer else {
return nil
}
return DER.unsignedInteger(inner.content)
}

private func string(fromReceiptValue value: [UInt8]) -> String? {
var index = 0
guard
let inner = DER.element(value, &index),
inner.tag == DER.utf8String || inner.tag == DER.ia5String || inner.tag == DER.printableString
else {
return nil
}
return String(bytes: inner.content, encoding: .utf8)
}

Indexing now; will likely complete sometime after mas exits
// Minimal DER reader supporting definite-length encodings (which is all DER uses).
private enum DER {
static let integer: UInt8 = 0x02
static let octetString: UInt8 = 0x04
static let utf8String: UInt8 = 0x0C
static let printableString: UInt8 = 0x13
static let ia5String: UInt8 = 0x16
static let sequence: UInt8 = 0x30
static let constructed: UInt8 = 0x20

Disable auto-indexing via: export MAS_NO_AUTO_INDEX=1
""",
separator: "",
)
Task {
do {
_ = try await run(
"/usr/bin/mdimport",
installedAppURL.filePath,
errorMessage: "Failed to index the Spotlight data for \(installedAppURL.filePath)",
)
} catch {
MAS.printer.error(error: error)
}
}
}
// Parse one tag-length-value element starting at `index`, advancing `index` past it.
static func element(_ bytes: [UInt8], _ index: inout Int) -> (tag: UInt8, content: [UInt8])? {
guard index < bytes.count else {
return nil
}
let tag = bytes[index]
index += 1
guard index < bytes.count else {
return nil
}
var length = Int(bytes[index])
index += 1
if length & 0x80 != 0 {
let byteCount = length & 0x7F
guard byteCount >= 1, byteCount <= 4, index + byteCount <= bytes.count else {
return nil
}

continuation.resume(returning: installedApps)
length = 0
for _ in 0 ..< byteCount {
length = (length << 8) | Int(bytes[index])
index += 1
}
}
guard length >= 0, index + length <= bytes.count else {
return nil
}
let content = Array(bytes[index ..< (index + length)])
index += length
return (tag, content)
}

query.start()
static func unsignedInteger(_ bytes: [UInt8]) -> UInt64 {
var value: UInt64 = 0
for byte in bytes {
value = (value << 8) | UInt64(byte)
}
return value
}
Comment on lines +430 to 436
}
Comment on lines +324 to 437

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Correctness & Performance Optimization

  1. Avoid Certificate/Signature Collisions (Correctness): The current implementation recursively walks the entire PKCS#7 container (including certificates, CRLs, and signer infos). If any certificate contains a sequence matching the receipt attribute pattern (e.g., in custom extensions), it can overwrite the actual receipt's adamID, bundleID, or version with garbage or certificate values. We should locate the eContent payload (OID 1.2.840.113549.1.7.1) and only parse that payload.
  2. Avoid Memory Allocations (Performance): Converting Data to [UInt8] and slicing it via Array(...) creates many unnecessary allocations and copies. Using Data directly with its native slicing capabilities avoids all copies since Data slices share the same underlying storage.
private func receiptAttributes(_ receipt: Data) -> [Int: Data] {
	var attributes: [Int: Data] = [:]

	func attribute(_ sequence: Data) -> (type: Int, value: Data)? {
		var index = sequence.startIndex
		guard let typeElement = DER.element(sequence, &index), typeElement.tag == DER.integer else {
			return nil
		}
		guard let versionElement = DER.element(sequence, &index), versionElement.tag == DER.integer else {
			return nil
		}
		guard let valueElement = DER.element(sequence, &index), valueElement.tag == DER.octetString else {
			return nil
		}
		guard index == sequence.endIndex else {
			return nil // exactly three elements
		}
		_ = versionElement
		return (Int(DER.unsignedInteger(typeElement.content)), valueElement.content)
	}

	func walk(_ data: Data) {
		var index = data.startIndex
		while index < data.endIndex {
			let start = index
			guard let node = DER.element(data, &index) else {
				break
			}
			if node.tag == DER.sequence, let attribute = attribute(node.content) {
				attributes[attribute.type] = attribute.value
			} else if node.tag & DER.constructed != 0 {
				walk(node.content) // descend into SEQUENCE / SET / context-tagged nodes
			} else if node.tag == DER.octetString {
				walk(node.content) // the eContent payload is wrapped in an OCTET STRING
			}
			if index <= start {
				break
			}
		}
	}

	// Find the eContent payload (the actual receipt SET) inside the PKCS#7 container
	let oid = Data([0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x07, 0x01])
	guard let range = receipt.firstRange(of: oid) else {
		return [:]
	}
	var index = range.upperBound
	guard let container = DER.element(receipt, &index), container.tag == 0xA0 else {
		return [:]
	}
	var innerIndex = container.content.startIndex
	guard let octetString = DER.element(container.content, &innerIndex), octetString.tag == DER.octetString else {
		return [:]
	}

	walk(octetString.content)
	return attributes
}

private func adamID(fromReceiptValue value: Data) -> ADAMID? {
	var index = value.startIndex
	guard let inner = DER.element(value, &index), inner.tag == DER.integer else {
		return nil
	}
	return DER.unsignedInteger(inner.content)
}

private func string(fromReceiptValue value: Data) -> String? {
	var index = value.startIndex
	guard
		let inner = DER.element(value, &index),
		inner.tag == DER.utf8String || inner.tag == DER.ia5String || inner.tag == DER.printableString
	else {
		return nil
	}
	return String(bytes: inner.content, encoding: .utf8)
}

// Minimal DER reader supporting definite-length encodings (which is all DER uses).
private enum DER {
	static let integer: UInt8 = 0x02
	static let octetString: UInt8 = 0x04
	static let utf8String: UInt8 = 0x0C
	static let printableString: UInt8 = 0x13
	static let ia5String: UInt8 = 0x16
	static let sequence: UInt8 = 0x30
	static let constructed: UInt8 = 0x20

	// Parse one tag-length-value element starting at `index`, advancing `index` past it.
	static func element(_ data: Data, _ index: inout Data.Index) -> (tag: UInt8, content: Data)? {
		guard index < data.endIndex else {
			return nil
		}
		let tag = data[index]
		index = data.index(after: index)
		guard index < data.endIndex else {
			return nil
		}
		var length = Int(data[index])
		index = data.index(after: index)
		if length & 0x80 != 0 {
			let byteCount = length & 0x7F
			guard byteCount >= 1, byteCount <= 4, data.index(index, offsetBy: byteCount, limitedBy: data.endIndex) != nil else {
				return nil
			}
			length = 0
			for _ in 0 ..< byteCount {
				length = (length << 8) | Int(data[index])
				index = data.index(after: index)
			}
		}
		guard length >= 0, let nextIndex = data.index(index, offsetBy: length, limitedBy: data.endIndex) else {
			return nil
		}
		let content = data[index ..< nextIndex]
		index = nextIndex
		return (tag, content)
	}

	static func unsignedInteger(_ data: Data) -> UInt64 {
		var value: UInt64 = 0
		for byte in data {
			value = (value << 8) | UInt64(byte)
		}
		return value
	}
}


Expand Down