diff --git a/README.md b/README.md index 7fe1d1b..a599333 100644 --- a/README.md +++ b/README.md @@ -924,6 +924,144 @@ On iOS, `HapticFeedback` uses a `CHHapticEngine` instance that is created on fir On Android, patterns are converted to a `VibrationEffect.Composition` with the corresponding `PRIMITIVE_*` constants and played through the system `Vibrator` obtained from `VibratorManager`. For more details, see Android's [VibrationEffect documentation](https://developer.android.com/reference/android/os/VibrationEffect) and the guide on [custom haptic effects](https://developer.android.com/develop/ui/views/haptics/custom-haptic-effects). +## SBOMView + +`SBOMView` is a SwiftUI view that renders a "Software Bill of Materials" (SBOM) +screen for your app. It reads SPDX 2.3 JSON files generated by the +`skip sbom create` command and presents the bundled software dependencies as a +navigable list, with a detail view for each dependency that shows its license, +version, supplier, checksums, and any further packages it pulls in. + +It is designed to be dropped straight into a Settings or Preferences screen via +a `NavigationLink`: + +```swift +import SwiftUI +import SkipKit + +struct SettingsView: View { + var body: some View { + Form { + // …other settings… + NavigationLink("Bill of Materials") { + SBOMView(bundle: Bundle.module) + } + } + } +} +``` + +### Generating SBOM resources + +Use the `skip sbom create` command to produce the SPDX files and place them +inside the resources of the module that owns the SBOM screen (typically the +same module that exposes your app's settings UI): + +``` +skip sbom create +``` + +The command produces both `sbom-darwin-ios.spdx.json` and +`sbom-linux-android.spdx.json`. Drop them into your module's `Resources/` +directory and make sure your `Package.swift` target processes that +resources folder: + +```swift +.target( + name: "MyAppUI", + dependencies: [.product(name: "SkipKit", package: "skip-kit")], + resources: [.process("Resources")] +) +``` + +### Display modes + +`SBOMView` supports two display modes via the `SBOMDisplayMode` enum: + +- `.hierarchy` *(default)* — Shows only the **top-level** dependencies (the + packages your app depends on directly), and lets the user drill into each + one to see its transitive dependencies. The hierarchy is reconstructed from + the `DEPENDS_ON` relationships in the SPDX document, so the screen mirrors + the actual dependency tree of your app. +- `.flat` — Shows **every** package in the SBOM as a single alphabetised + list, regardless of where it sits in the dependency tree. This is useful + for audits, search-and-find workflows, or for showing a complete catalogue + of every binary that ends up in your app. + +```swift +// Hierarchy view (the default): top-level deps only, drill in for transitives +NavigationLink("Bill of Materials") { + SBOMView(bundle: Bundle.module) +} + +// Flat view: every package, sorted alphabetically +NavigationLink("All Dependencies") { + SBOMView(bundle: Bundle.module, displayMode: .flat) +} +``` + +In both modes the dependency lists are sorted alphabetically by package name +(case-insensitive) for predictable presentation. + +### What the user sees + +The summary list shows each dependency's name, supplier, version, and +declared license. Tapping a row pushes a detail view that includes: + +- **Package** — name, version, supplier, originator, purpose, SPDX ID +- **License** — declared and concluded licenses, copyright text, and a + *"View License on spdx.org"* button that opens the corresponding page on + [spdx.org/licenses](https://spdx.org/licenses/) (e.g. tapping `EUPL-1.2` + opens `https://spdx.org/licenses/EUPL-1.2.html`) inside an in-app browser + (`SFSafariViewController` on iOS, Chrome Custom Tabs on Android). For + custom licenses identified by `LicenseRef-…`, the full license text and + any "see also" links are displayed inline from the SBOM's + `hasExtractedLicensingInfos` section. +- **Dependencies** — the direct sub-dependencies of this package, each as a + navigation link to its own detail view. This is what makes the hierarchy + view navigable: you start at your top-level deps and walk down through the + tree. +- **Source** — download location, homepage, description, summary +- **External References** — `purl`, `swiftpm`, and other SPDX external refs +- **Checksums** — SHA-1 / SHA-256 / etc. for each package archive + +The summary list also includes a *"Share Bill of Materials"* button that +brings up the system share sheet so users (or auditors) can export the raw +SPDX JSON for offline review. + +### Parsing SBOMs programmatically + +The same SPDX model used by `SBOMView` is also exposed for direct use, which +is handy for tests, custom UI, or build-time tooling: + +```swift +if let document = try SBOMDocument.load(from: Bundle.module) { + for package in document.topLevelPackages { + print("\(package.name ?? "?") \(package.versionInfo ?? "")") + print(" License: \(package.licenseDeclared ?? "NOASSERTION")") + for dep in document.directDependencies(of: package) { + print(" └── \(dep.name ?? "?")") + } + } +} +``` + +The `SPDXLicense` helper provides utilities for working with SPDX license +identifiers, including extracting a canonical id from compound expressions +like `LGPL-3.0-only WITH LGPL-3.0-linking-exception` and constructing the +corresponding `https://spdx.org/licenses/.html` URL: + +```swift +SPDXLicense.licensePageURL(for: "EUPL-1.2") +// → https://spdx.org/licenses/EUPL-1.2.html + +SPDXLicense.canonicalIdentifier("MIT OR Apache-2.0") +// → "MIT" + +SPDXLicense.isUnknown("NOASSERTION") +// → true +``` + ## Building This project is a Swift Package Manager module that uses the diff --git a/Sources/SkipKit/Resources/Localizable.xcstrings b/Sources/SkipKit/Resources/Localizable.xcstrings index 172aa10..98a0143 100644 --- a/Sources/SkipKit/Resources/Localizable.xcstrings +++ b/Sources/SkipKit/Resources/Localizable.xcstrings @@ -1,6 +1,9 @@ { "sourceLanguage" : "en", "strings" : { + "About this Bill of Materials" : { + + }, "Appearance" : { "extractionState" : "stale", "localizations" : { @@ -29,6 +32,24 @@ } } } + }, + "Bill of Materials" : { + + }, + "Checksums" : { + + }, + "Comments" : { + + }, + "Concluded" : { + + }, + "Copyright" : { + + }, + "Created" : { + }, "Dark" : { "extractionState" : "stale", @@ -58,6 +79,33 @@ } } } + }, + "Data License" : { + + }, + "Declared" : { + + }, + "Dependencies (%lld)" : { + + }, + "Description" : { + + }, + "Display Mode" : { + + }, + "Download" : { + + }, + "External References" : { + + }, + "Flattened" : { + + }, + "Generator" : { + }, "Hello %@!" : { "extractionState" : "stale", @@ -87,6 +135,9 @@ } } } + }, + "Hierarchical" : { + }, "Home" : { "extractionState" : "stale", @@ -116,6 +167,9 @@ } } } + }, + "Homepage" : { + }, "Item %lld" : { "extractionState" : "stale", @@ -145,6 +199,15 @@ } } } + }, + "License" : { + + }, + "License Name" : { + + }, + "License Text" : { + }, "Light" : { "extractionState" : "stale", @@ -176,7 +239,6 @@ } }, "Name" : { - "extractionState" : "stale", "localizations" : { "es" : { "stringUnit" : { @@ -203,6 +265,12 @@ } } } + }, + "Originator" : { + + }, + "Package" : { + }, "Powered by %@" : { "extractionState" : "stale", @@ -232,6 +300,9 @@ } } } + }, + "Purpose" : { + }, "Screen %lld" : { "extractionState" : "stale", @@ -261,6 +332,9 @@ } } } + }, + "See Also" : { + }, "Settings" : { "extractionState" : "stale", @@ -290,6 +364,27 @@ } } } + }, + "Share Bill of Materials" : { + + }, + "Software Bill of Materials" : { + + }, + "Source" : { + + }, + "SPDX ID" : { + + }, + "SPDX Version" : { + + }, + "Summary" : { + + }, + "Supplier" : { + }, "System" : { "extractionState" : "stale", @@ -319,6 +414,24 @@ } } } + }, + "Tap “View License on spdx.org” to read the full text of recognised open-source licenses." : { + + }, + "These packages are pulled in transitively by %@." : { + + }, + "This app includes the open-source software listed above. Tap a dependency to view its license and version information." : { + + }, + "This app includes the open-source software listed above. Tap a dependency to view its license, version information, and any further dependencies it brings in." : { + + }, + "Version" : { + + }, + "View License on spdx.org" : { + }, "Welcome" : { "extractionState" : "stale", diff --git a/Sources/SkipKit/SBOM.swift b/Sources/SkipKit/SBOM.swift new file mode 100644 index 0000000..73f9bb2 --- /dev/null +++ b/Sources/SkipKit/SBOM.swift @@ -0,0 +1,472 @@ +// Copyright 2025–2026 Skip +// SPDX-License-Identifier: MPL-2.0 +#if !SKIP_BRIDGE +import Foundation + +// MARK: - SPDX SBOM Model + +/// A parsed SPDX (Software Package Data Exchange) Software Bill of Materials document. +/// +/// This is a Codable representation of the SPDX 2.3 JSON format produced by tools like +/// `skip sbom create` (for the iOS/SwiftPM dependency tree) and the `spdx-gradle-plugin` +/// (for the Android/Gradle dependency tree). +/// +/// See: https://spdx.github.io/spdx-spec/v2.3/ +public struct SBOMDocument: Codable, Hashable { + /// The SPDX version this document conforms to (e.g., `SPDX-2.3`). + public var spdxVersion: String? + /// The SPDX identifier for this document (typically `SPDXRef-DOCUMENT`). + public var SPDXID: String? + /// The data license for the SPDX document content (typically `CC0-1.0`). + public var dataLicense: String? + /// A human-readable name for the document. + public var name: String? + /// A unique URI namespace identifying this document. + public var documentNamespace: String? + /// Information about how and when the SBOM was created. + public var creationInfo: SBOMCreationInfo? + /// The packages described by this SBOM. + public var packages: [SBOMPackage] + /// Relationships between SPDX elements (e.g., DEPENDS_ON). + public var relationships: [SBOMRelationship]? + /// Custom (non-SPDX-listed) license definitions used by packages in this document. + public var hasExtractedLicensingInfos: [SBOMExtractedLicense]? + + public init( + spdxVersion: String? = nil, + SPDXID: String? = nil, + dataLicense: String? = nil, + name: String? = nil, + documentNamespace: String? = nil, + creationInfo: SBOMCreationInfo? = nil, + packages: [SBOMPackage] = [], + relationships: [SBOMRelationship]? = nil, + hasExtractedLicensingInfos: [SBOMExtractedLicense]? = nil + ) { + self.spdxVersion = spdxVersion + self.SPDXID = SPDXID + self.dataLicense = dataLicense + self.name = name + self.documentNamespace = documentNamespace + self.creationInfo = creationInfo + self.packages = packages + self.relationships = relationships + self.hasExtractedLicensingInfos = hasExtractedLicensingInfos + } +} + +/// Metadata about how the SBOM was generated. +public struct SBOMCreationInfo: Codable, Hashable { + /// ISO 8601 timestamp of when the document was created. + public var created: String? + /// The tools and/or organizations that created this document. + public var creators: [String]? + /// The version of the SPDX license list used. + public var licenseListVersion: String? + + public init(created: String? = nil, creators: [String]? = nil, licenseListVersion: String? = nil) { + self.created = created + self.creators = creators + self.licenseListVersion = licenseListVersion + } +} + +/// A single package (dependency) tracked in the SBOM. +public struct SBOMPackage: Codable, Hashable, Identifiable { + /// The SPDX identifier for this package. + public var SPDXID: String? + /// Human-readable package name. + public var name: String? + /// Version string for the package. + public var versionInfo: String? + /// Supplier (organization or person) that distributes the package. + public var supplier: String? + /// Originator (the party that originally created the package). + public var originator: String? + /// URL or other locator describing where the package can be downloaded. + public var downloadLocation: String? + /// Free-form description of the package. + public var description: String? + /// A short summary of the package. + public var summary: String? + /// The package homepage. + public var homepage: String? + /// The license that the SBOM author concluded applies (after analysis). + public var licenseConcluded: String? + /// The license that the package author declared in the package. + public var licenseDeclared: String? + /// Additional comments about the license. + public var licenseComments: String? + /// Copyright notices declared by the package. + public var copyrightText: String? + /// What this package is for (e.g., `LIBRARY`, `APPLICATION`). + public var primaryPackagePurpose: String? + /// Source-info string (often from the gradle plugin). + public var sourceInfo: String? + /// Whether the file contents of the package were analyzed. + public var filesAnalyzed: Bool? + /// Cryptographic checksums for the package archive. + public var checksums: [SBOMChecksum]? + /// External references such as `purl` (package URL) and SwiftPM repository URLs. + public var externalRefs: [SBOMExternalRef]? + /// License information extracted from the package's files. + public var licenseInfoFromFiles: [String]? + + public var id: String { SPDXID ?? (name ?? "") } + + public init( + SPDXID: String? = nil, + name: String? = nil, + versionInfo: String? = nil, + supplier: String? = nil, + originator: String? = nil, + downloadLocation: String? = nil, + description: String? = nil, + summary: String? = nil, + homepage: String? = nil, + licenseConcluded: String? = nil, + licenseDeclared: String? = nil, + licenseComments: String? = nil, + copyrightText: String? = nil, + primaryPackagePurpose: String? = nil, + sourceInfo: String? = nil, + filesAnalyzed: Bool? = nil, + checksums: [SBOMChecksum]? = nil, + externalRefs: [SBOMExternalRef]? = nil, + licenseInfoFromFiles: [String]? = nil + ) { + self.SPDXID = SPDXID + self.name = name + self.versionInfo = versionInfo + self.supplier = supplier + self.originator = originator + self.downloadLocation = downloadLocation + self.description = description + self.summary = summary + self.homepage = homepage + self.licenseConcluded = licenseConcluded + self.licenseDeclared = licenseDeclared + self.licenseComments = licenseComments + self.copyrightText = copyrightText + self.primaryPackagePurpose = primaryPackagePurpose + self.sourceInfo = sourceInfo + self.filesAnalyzed = filesAnalyzed + self.checksums = checksums + self.externalRefs = externalRefs + self.licenseInfoFromFiles = licenseInfoFromFiles + } +} + +/// A cryptographic checksum entry on a package. +public struct SBOMChecksum: Codable, Hashable { + /// Hash algorithm name (e.g., `SHA1`, `SHA256`). + public var algorithm: String? + /// The hex-encoded checksum value. + public var checksumValue: String? + + public init(algorithm: String? = nil, checksumValue: String? = nil) { + self.algorithm = algorithm + self.checksumValue = checksumValue + } +} + +/// An external reference attached to a package, such as a Package URL (`purl`) or SwiftPM repository. +public struct SBOMExternalRef: Codable, Hashable { + /// Category (e.g., `PACKAGE-MANAGER`, `SECURITY`). + public var referenceCategory: String? + /// The locator string (the meaning depends on `referenceType`). + public var referenceLocator: String? + /// The type of reference (e.g., `purl`, `swiftpm`). + public var referenceType: String? + + public init(referenceCategory: String? = nil, referenceLocator: String? = nil, referenceType: String? = nil) { + self.referenceCategory = referenceCategory + self.referenceLocator = referenceLocator + self.referenceType = referenceType + } +} + +/// A relationship between two SPDX elements (e.g., `A DEPENDS_ON B`). +public struct SBOMRelationship: Codable, Hashable { + public var spdxElementId: String? + public var relatedSpdxElement: String? + public var relationshipType: String? + + public init(spdxElementId: String? = nil, relatedSpdxElement: String? = nil, relationshipType: String? = nil) { + self.spdxElementId = spdxElementId + self.relatedSpdxElement = relatedSpdxElement + self.relationshipType = relationshipType + } +} + +/// A custom license definition for licenses that are not on the SPDX License List. +/// Identified by `LicenseRef-…` rather than a standard SPDX identifier. +public struct SBOMExtractedLicense: Codable, Hashable { + /// The `LicenseRef-…` identifier used to refer to this license. + public var licenseId: String? + /// The full text of the license. + public var extractedText: String? + /// A human-readable name for the license. + public var name: String? + /// URLs where the license text or more information can be found. + public var seeAlsos: [String]? + + public init(licenseId: String? = nil, extractedText: String? = nil, name: String? = nil, seeAlsos: [String]? = nil) { + self.licenseId = licenseId + self.extractedText = extractedText + self.name = name + self.seeAlsos = seeAlsos + } +} + +// MARK: - Loading + +/// The standard resource name (without extension) for the iOS/Darwin SPDX SBOM file. +public let sbomDarwinResourceName = "sbom-darwin-ios.spdx" +/// The standard resource name (without extension) for the Android/Linux SPDX SBOM file. +public let sbomLinuxAndroidResourceName = "sbom-linux-android.spdx" +/// The file extension for SPDX SBOM files. +public let sbomResourceExtension = "json" + +extension SBOMDocument { + /// The default resource name for the SBOM appropriate for the current platform + /// (`sbom-darwin-ios.spdx` on Apple platforms, `sbom-linux-android.spdx` on Android). + public static var defaultResourceName: String { + #if os(Android) + return sbomLinuxAndroidResourceName + #else + return sbomDarwinResourceName + #endif + } + + /// Loads the SBOM document for the current platform from the given bundle, if present. + /// + /// On Apple platforms this looks for `sbom-darwin-ios.spdx.json`. On Android, it looks + /// for `sbom-linux-android.spdx.json`. + /// + /// - Parameter bundle: The bundle containing the SBOM resource. + /// - Returns: The parsed `SBOMDocument`, or `nil` if no SBOM resource is present in the bundle. + /// - Throws: A decoding error if the resource exists but cannot be parsed as SPDX JSON. + public static func load(from bundle: Bundle) throws -> SBOMDocument? { + guard let url = bundle.url(forResource: defaultResourceName, withExtension: sbomResourceExtension) else { + return nil + } + return try load(from: url) + } + + /// Loads and parses an SPDX JSON SBOM document from the given file URL. + public static func load(from url: URL) throws -> SBOMDocument { + let data = try Data(contentsOf: url) + return try parse(data: data) + } + + /// Parses an SPDX JSON SBOM document from raw `Data`. + public static func parse(data: Data) throws -> SBOMDocument { + let decoder = JSONDecoder() + return try decoder.decode(SBOMDocument.self, from: data) + } + + /// Returns the raw bytes of the SBOM resource for the current platform from the given + /// bundle, if present. Useful for sharing the file via the system share sheet without + /// re-encoding it. + public static func rawData(from bundle: Bundle) -> Data? { + guard let url = bundle.url(forResource: defaultResourceName, withExtension: sbomResourceExtension) else { + return nil + } + return try? Data(contentsOf: url) + } + + /// Returns the URL of the SBOM resource for the current platform from the given + /// bundle, if present. + public static func resourceURL(in bundle: Bundle) -> URL? { + return bundle.url(forResource: defaultResourceName, withExtension: sbomResourceExtension) + } + + /// Returns `true` if the given bundle contains an SBOM resource for the current platform. + public static func bundleContainsSBOM(_ bundle: Bundle) -> Bool { + return resourceURL(in: bundle) != nil + } +} + +extension SBOMDocument { + /// All packages in the document excluding any "root" application packages (those with + /// `primaryPackagePurpose == "APPLICATION"` or those that match the document name), + /// sorted alphabetically by name (case-insensitive). This is the "flat" list of every + /// dependency users typically want to see in a Bill of Materials view. + public var dependencyPackages: [SBOMPackage] { + let docName = self.name ?? "" + let filtered = packages.filter { pkg in + if pkg.primaryPackagePurpose == "APPLICATION" { + return false + } + // Filter out the root project package, which the gradle plugin emits with the + // document name and a sourceInfo of "git+...". + if let pkgName = pkg.name, pkgName == docName { + return false + } + return true + } + return sortedByName(filtered) + } + + /// The SPDX identifier of the root package described by this document, found via the + /// `SPDXRef-DOCUMENT DESCRIBES ` relationship. Falls back to the first package + /// with `primaryPackagePurpose == "APPLICATION"` if no `DESCRIBES` relationship exists. + public var rootPackageSPDXID: String? { + if let rels = relationships { + for rel in rels { + if rel.relationshipType == "DESCRIBES" && rel.spdxElementId == "SPDXRef-DOCUMENT" { + if let related = rel.relatedSpdxElement { + return related + } + } + } + } + for pkg in packages { + if pkg.primaryPackagePurpose == "APPLICATION" { + return pkg.SPDXID + } + } + return nil + } + + /// Looks up a package by its SPDX identifier. + public func package(forSPDXID spdxId: String) -> SBOMPackage? { + for pkg in packages { + if pkg.SPDXID == spdxId { + return pkg + } + } + return nil + } + + /// Returns the packages directly depended on by the package with the given SPDX + /// identifier (i.e., ` DEPENDS_ON X`), sorted alphabetically by name. + /// If the document has no relationships, returns an empty array. + public func directDependencies(ofSPDXID spdxId: String) -> [SBOMPackage] { + guard let rels = relationships else { return [] } + var result: [SBOMPackage] = [] + for rel in rels { + if rel.relationshipType != "DEPENDS_ON" { continue } + if rel.spdxElementId != spdxId { continue } + guard let target = rel.relatedSpdxElement else { continue } + if let pkg = package(forSPDXID: target) { + result.append(pkg) + } + } + return sortedByName(result) + } + + /// Returns the direct dependencies of the given package, sorted alphabetically by name. + public func directDependencies(of package: SBOMPackage) -> [SBOMPackage] { + guard let id = package.SPDXID else { return [] } + return directDependencies(ofSPDXID: id) + } + + /// The top-level dependency packages: the packages directly depended on by the document's + /// root package via the `DEPENDS_ON` relationship, sorted alphabetically by name. + /// + /// If the document does not contain a `DESCRIBES` relationship or any `DEPENDS_ON` + /// relationships from the root, this falls back to `dependencyPackages` so the + /// hierarchy view still has something useful to display. + public var topLevelPackages: [SBOMPackage] { + guard let rootId = rootPackageSPDXID else { + return dependencyPackages + } + let direct = directDependencies(ofSPDXID: rootId) + if direct.isEmpty { + return dependencyPackages + } + return direct + } + + /// Looks up an extracted license by its `LicenseRef-…` identifier. + public func extractedLicense(forId licenseId: String) -> SBOMExtractedLicense? { + guard let infos = hasExtractedLicensingInfos else { return nil } + for info in infos { + if info.licenseId == licenseId { + return info + } + } + return nil + } + + /// Sorts an array of packages alphabetically by `name`, case-insensitively. Packages + /// without a name are pushed to the end of the list. + private func sortedByName(_ pkgs: [SBOMPackage]) -> [SBOMPackage] { + return pkgs.sorted { lhs, rhs in + let l = (lhs.name ?? "~").lowercased() + let r = (rhs.name ?? "~").lowercased() + return l < r + } + } +} + +/// Controls how `SBOMView` presents the bundled software dependencies. +public enum SBOMDisplayMode: String, Hashable { + /// Show only the top-level dependencies (those directly depended on by the + /// document's root package via the `DEPENDS_ON` relationship). The detail view + /// for each package then lists its own direct dependencies, allowing the user + /// to navigate the dependency tree. + case hierarchy + /// Show every dependency package in the SBOM as a single flat, alphabetised list. + case flat +} + +// MARK: - License helpers + +/// Helpers for working with SPDX license identifiers. +public enum SPDXLicense { + /// `NOASSERTION` is the SPDX sentinel meaning "no information was provided". + public static let noAssertion = "NOASSERTION" + /// `NONE` is the SPDX sentinel meaning "the field has explicitly no value". + public static let none = "NONE" + + /// Returns `true` if the given license string is missing or one of the SPDX + /// "no information" sentinels (`NOASSERTION`, `NONE`). + public static func isUnknown(_ license: String?) -> Bool { + guard let license = license else { return true } + if license.isEmpty { return true } + if license == noAssertion { return true } + if license == none { return true } + return false + } + + /// Returns the SPDX license identifier suitable for linking to spdx.org/licenses/, + /// stripping any compound expressions like `WITH` clauses or `OR`/`AND` operators. + /// Returns `nil` if no usable identifier can be extracted, or if the identifier is + /// a `LicenseRef-…` (which is a custom license, not on the SPDX list). + public static func canonicalIdentifier(_ license: String?) -> String? { + guard let raw = license else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if isUnknown(trimmed) { return nil } + // Strip enclosing parentheses + var s = trimmed + while s.hasPrefix("(") && s.hasSuffix(")") { + s = String(s.dropFirst().dropLast()).trimmingCharacters(in: .whitespacesAndNewlines) + } + // Take the first identifier from a compound expression like "MIT OR Apache-2.0" + // or "LGPL-3.0-only WITH LGPL-3.0-linking-exception". + let separators = [" WITH ", " OR ", " AND ", " or ", " and ", " with "] + var head = s + for sep in separators { + if let range = head.range(of: sep) { + head = String(head[head.startIndex.. URL? { + guard let id = canonicalIdentifier(license) else { return nil } + return URL(string: "https://spdx.org/licenses/\(id).html") + } +} + +#endif // !SKIP_BRIDGE diff --git a/Sources/SkipKit/SBOMView.swift b/Sources/SkipKit/SBOMView.swift new file mode 100644 index 0000000..b726a11 --- /dev/null +++ b/Sources/SkipKit/SBOMView.swift @@ -0,0 +1,504 @@ +// Copyright 2025–2026 Skip +// SPDX-License-Identifier: MPL-2.0 +#if !SKIP_BRIDGE +import Foundation +import SwiftUI + +/// A "Software Bill of Materials" view that displays the third-party software dependencies +/// of the host app, parsed from SPDX SBOM resource files in the given bundle. +/// +/// The view looks for SBOM resources generated by `skip sbom create`: +/// +/// - On Apple platforms: `sbom-darwin-ios.spdx.json` +/// - On Android: `sbom-linux-android.spdx.json` +/// +/// Each dependency is presented as a row showing its name, supplier, version, and declared +/// license. Tapping a row navigates to a detail view that shows all SPDX fields for the +/// package, including checksums and external references. Where the declared license is a +/// recognised SPDX identifier (e.g., `EUPL-1.2`, `Apache-2.0`), the detail view exposes a +/// button that opens the corresponding page on https://spdx.org/licenses/ in an in-app +/// browser, so users can review the full license text. +/// +/// The list also includes a button that brings up the system share sheet so users can +/// export the raw SPDX JSON for offline review or auditing. +/// +/// `SBOMView` is intended to be dropped into a Settings or Preferences view via a +/// `NavigationLink`: +/// +/// ```swift +/// NavigationLink("Bill of Materials") { +/// SBOMView(bundle: Bundle.module) +/// } +/// ``` +/// +/// This view exists in part to comply with open-source license obligations that require +/// distributing apps to mention the licenses of included software. +public struct SBOMView: View { + let bundle: Bundle + let displayMode: SBOMDisplayMode + @State private var document: SBOMDocument? = nil + @State private var loadError: String? = nil + + public init(bundle: Bundle, displayMode: SBOMDisplayMode = .hierarchy) { + self.bundle = bundle + self.displayMode = displayMode + } + + public var body: some View { + contentView + .navigationTitle("Bill of Materials") + .task { + await loadDocument() + } + } + + @ViewBuilder + private var contentView: some View { + if let doc = document { + SBOMListView(document: doc, bundle: bundle, displayMode: displayMode) + } else if let error = loadError { + SBOMEmptyView(message: error) + } else { + SBOMEmptyView(message: "No software bill of materials is available for this build.") + } + } + + private func loadDocument() async { + do { + let doc = try SBOMDocument.load(from: bundle) + self.document = doc + } catch { + self.loadError = "Failed to parse software bill of materials: \(error.localizedDescription)" + } + } +} + +struct SBOMEmptyView: View { + let message: String + + var body: some View { + VStack(spacing: 12) { + Image(systemName: "doc.text.magnifyingglass") + .font(.system(size: 48)) + .foregroundStyle(.secondary) + Text(message) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - List + +struct SBOMListView: View { + let document: SBOMDocument + let bundle: Bundle + @State private var displayMode: SBOMDisplayMode + + init(document: SBOMDocument, bundle: Bundle, displayMode: SBOMDisplayMode) { + self.document = document + self.bundle = bundle + self._displayMode = State(initialValue: displayMode) + } + + /// The packages shown at the top of the list, depending on the current display mode. + private var listedPackages: [SBOMPackage] { + switch displayMode { + case .hierarchy: + return document.topLevelPackages + case .flat: + return document.dependencyPackages + } + } + + var body: some View { + List { + // we do not show the picker on iOS because the was SBOMs are generated are always flattened (because it is derived from the Package.resolved, which does not contain any information about which packages depend on which other packages) + #if os(Android) + Section { + Picker("Display Mode", selection: $displayMode) { + Text("Flattened").tag(SBOMDisplayMode.flat) + Text("Hierarchical").tag(SBOMDisplayMode.hierarchy) + } + .pickerStyle(.segmented) + } + #endif + + Section { + ForEach(listedPackages) { pkg in + NavigationLink(destination: SBOMPackageDetailView(package: pkg, document: document)) { + SBOMPackageRow(package: pkg) + } + } + } header: { + Text("Dependencies (\(listedPackages.count))") + } footer: { + Text(footerText) + } + + Section { + if let created = document.creationInfo?.created, !created.isEmpty { + SBOMFieldRow(label: "Created", value: created) + } + if let creators = document.creationInfo?.creators, !creators.isEmpty { + SBOMFieldRow(label: "Generator", value: creators.joined(separator: ", ")) + } + if let version = document.spdxVersion, !version.isEmpty { + SBOMFieldRow(label: "SPDX Version", value: version) + } + if let dataLicense = document.dataLicense, !dataLicense.isEmpty { + SBOMFieldRow(label: "Data License", value: dataLicense) + } + + SBOMShareButton(document: document, bundle: bundle) + } header: { + Text("About this Bill of Materials") + } + } + } + + private var footerText: LocalizedStringKey { + switch displayMode { + case .hierarchy: + return "This app includes the open-source software listed above. Tap a dependency to view its license, version information, and any further dependencies it brings in." + case .flat: + return "This app includes the open-source software listed above. Tap a dependency to view its license and version information." + } + } +} + +struct SBOMPackageRow: View { + let package: SBOMPackage + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline) { + Text(package.name ?? "Unnamed package") + .font(.headline) + .lineLimit(1) + Spacer() + if let version = package.versionInfo, !SPDXLicense.isUnknown(version) { + Text(version) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + if let supplier = package.supplier, !SPDXLicense.isUnknown(supplier) { + Text(prettySupplier(supplier)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + if let license = package.licenseDeclared, !SPDXLicense.isUnknown(license) { + Text(license) + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.15)) + .clipShape(Capsule()) + } + } + .padding(.vertical, 2) + } + + /// Strips the SPDX `Organization:` / `Person:` prefix from a supplier string. + private func prettySupplier(_ supplier: String) -> String { + if supplier.hasPrefix("Organization: ") { + return String(supplier.dropFirst("Organization: ".count)) + } + if supplier.hasPrefix("Person: ") { + return String(supplier.dropFirst("Person: ".count)) + } + return supplier + } +} + +// MARK: - Detail + +struct SBOMPackageDetailView: View { + let package: SBOMPackage + let document: SBOMDocument + + @State private var browserPresented = false + @State private var browserURL: URL = URL(string: "https://spdx.org/licenses/")! + + var body: some View { + List { + Section { + if let name = package.name, !name.isEmpty { + SBOMFieldRow(label: "Name", value: name) + } + if let version = package.versionInfo, !SPDXLicense.isUnknown(version) { + SBOMFieldRow(label: "Version", value: version) + } + if let supplier = package.supplier, !SPDXLicense.isUnknown(supplier) { + SBOMFieldRow(label: "Supplier", value: supplier) + } + if let originator = package.originator, !SPDXLicense.isUnknown(originator) { + SBOMFieldRow(label: "Originator", value: originator) + } + if let purpose = package.primaryPackagePurpose, !purpose.isEmpty { + SBOMFieldRow(label: "Purpose", value: purpose) + } + if let id = package.SPDXID, !id.isEmpty { + SBOMFieldRow(label: "SPDX ID", value: id) + } + } header: { + Text("Package") + } + + Section { + if let declared = package.licenseDeclared, !SPDXLicense.isUnknown(declared) { + SBOMFieldRow(label: "Declared", value: resolveLicenseName(declared)) + } + if let concluded = package.licenseConcluded, !SPDXLicense.isUnknown(concluded) { + SBOMFieldRow(label: "Concluded", value: resolveLicenseName(concluded)) + } + if let copyright = package.copyrightText, !SPDXLicense.isUnknown(copyright) { + SBOMFieldRow(label: "Copyright", value: copyright) + } + if let comments = package.licenseComments, !comments.isEmpty { + SBOMFieldRow(label: "Comments", value: comments) + } + if let licenseURL = licensePageURL() { + Button { + self.browserURL = licenseURL + self.browserPresented = true + } label: { + Text("View License on spdx.org") + } + } + // Show extracted-license details for LicenseRef-… ids + if let extracted = extractedLicenseInfo() { + if let name = extracted.name, !name.isEmpty { + SBOMFieldRow(label: "License Name", value: name) + } + if let text = extracted.extractedText, !text.isEmpty { + SBOMFieldRow(label: "License Text", value: text) + } + if let seeAlsos = extracted.seeAlsos { + ForEach(seeAlsos.map { SBOMSeeAlsoItem(id: "seeAlso-\($0)", url: $0) }) { item in + SBOMFieldRow(label: "See Also", value: item.url) + } + } + } + } header: { + Text("License") + } footer: { + Text("Tap “View License on spdx.org” to read the full text of recognised open-source licenses.") + } + + let subDependencies = document.directDependencies(of: package) + if !subDependencies.isEmpty { + Section { + ForEach(subDependencies) { dep in + NavigationLink(destination: SBOMPackageDetailView(package: dep, document: document)) { + SBOMPackageRow(package: dep) + } + } + } header: { + Text("Dependencies (\(subDependencies.count))") + } footer: { + Text("These packages are pulled in transitively by \(package.name ?? "this package").") + } + } + + if let download = package.downloadLocation, !SPDXLicense.isUnknown(download) { + Section { + SBOMFieldRow(label: "Download", value: download) + if let homepage = package.homepage, !homepage.isEmpty { + SBOMFieldRow(label: "Homepage", value: homepage) + } + if let description = package.description, !description.isEmpty { + SBOMFieldRow(label: "Description", value: description) + } + if let summary = package.summary, !summary.isEmpty { + SBOMFieldRow(label: "Summary", value: summary) + } + } header: { + Text("Source") + } + } + + if let refs = package.externalRefs, !refs.isEmpty { + Section { + ForEach(refs.indices.map { SBOMIndexedItem(id: "ref-\($0)", index: $0) }) { item in + SBOMExternalRefRow(ref: refs[item.index]) + } + } header: { + Text("External References") + } + } + + if let checksums = package.checksums, !checksums.isEmpty { + Section { + ForEach(checksums.indices.map { SBOMIndexedItem(id: "checksum-\($0)", index: $0) }) { item in + SBOMChecksumRow(checksum: checksums[item.index]) + } + } header: { + Text("Checksums") + } + } + } + .navigationTitle(package.name ?? "Package") + #if !SKIP + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + #endif + .openWebBrowser(isPresented: $browserPresented, url: browserURL, mode: .embeddedBrowser(params: nil)) + } + + private func licensePageURL() -> URL? { + if let url = SPDXLicense.licensePageURL(for: package.licenseDeclared) { + return url + } + return SPDXLicense.licensePageURL(for: package.licenseConcluded) + } + + /// Looks up an extracted license info entry if the package's `licenseDeclared` + /// or `licenseConcluded` is a `LicenseRef-…` identifier. + private func extractedLicenseInfo() -> SBOMExtractedLicense? { + let candidates = [package.licenseDeclared, package.licenseConcluded] + for candidate in candidates { + guard let id = candidate, id.hasPrefix("LicenseRef-") else { continue } + if let info = document.extractedLicense(forId: id) { + return info + } + } + return nil + } + + /// If the given license id is a `LicenseRef-…`, return its human-readable name when available. + private func resolveLicenseName(_ license: String) -> String { + if license.hasPrefix("LicenseRef-") { + if let info = document.extractedLicense(forId: license), let name = info.name, !name.isEmpty { + return "\(name) (\(license))" + } + } + return license + } +} + +struct SBOMExternalRefRow: View { + let ref: SBOMExternalRef + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + if let type = ref.referenceType, !type.isEmpty { + Text(type) + .font(.caption) + .foregroundStyle(.secondary) + } + if let locator = ref.referenceLocator, !locator.isEmpty { + Text(locator) + .font(.footnote) + #if !SKIP + .textSelection(.enabled) + #endif + } + } + .padding(.vertical, 2) + } +} + +struct SBOMChecksumRow: View { + let checksum: SBOMChecksum + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + if let alg = checksum.algorithm, !alg.isEmpty { + Text(alg) + .font(.caption) + .foregroundStyle(.secondary) + } + if let value = checksum.checksumValue, !value.isEmpty { + Text(value) + .font(.system(.footnote, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + #if !SKIP + .textSelection(.enabled) + #endif + } + } + .padding(.vertical, 2) + } +} + +// MARK: - ForEach helper items +// +// SwiftUI's `List` becomes a Compose `LazyColumn` on Android. Multiple `ForEach` +// blocks inside the same `List` share a single key namespace, so using `\.offset` +// or array indices in more than one `ForEach` causes a Compose +// `IllegalArgumentException: Key "0" was already used` crash when the list is +// scrolled. These small `Identifiable` wrappers give each row a globally unique +// string id within the enclosing list. + +struct SBOMSeeAlsoItem: Identifiable, Hashable { + let id: String + let url: String +} + +struct SBOMIndexedItem: Identifiable, Hashable { + let id: String + let index: Int +} + +// MARK: - Shared row helpers + +struct SBOMFieldRow: View { + let label: LocalizedStringKey + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.callout) + #if !SKIP + .textSelection(.enabled) + #endif + } + .padding(.vertical, 2) + } +} + +// MARK: - Share + +struct SBOMShareButton: View { + let document: SBOMDocument + let bundle: Bundle + + var body: some View { + if let text = sbomText() { + ShareLink(item: text, + subject: Text("Software Bill of Materials"), + message: Text(document.name ?? "SBOM")) { + Label("Share Bill of Materials", systemImage: "square.and.arrow.up") + } + } + } + + /// Returns the raw SPDX JSON text for the bundle's SBOM, if available, falling back to + /// re-encoding the parsed `document` if the original bytes cannot be read. + private func sbomText() -> String? { + if let data = SBOMDocument.rawData(from: bundle), let s = String(data: data, encoding: .utf8) { + return s + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + if let data = try? encoder.encode(document), let s = String(data: data, encoding: .utf8) { + return s + } + return nil + } +} + +#endif // !SKIP_BRIDGE diff --git a/Tests/SkipKitTests/Resources/sbom-test.spdx.json b/Tests/SkipKitTests/Resources/sbom-test.spdx.json new file mode 100644 index 0000000..68326a7 --- /dev/null +++ b/Tests/SkipKitTests/Resources/sbom-test.spdx.json @@ -0,0 +1,128 @@ +{ + "SPDXID" : "SPDXRef-DOCUMENT", + "spdxVersion" : "SPDX-2.3", + "creationInfo" : { + "created" : "2026-04-08T12:00:00Z", + "creators" : [ "Tool: skip-kit-test" ], + "licenseListVersion" : "3.22" + }, + "name" : "skip-kit-sbom-test", + "dataLicense" : "CC0-1.0", + "documentNamespace" : "https://skip.dev/spdx/skip-kit-test", + "hasExtractedLicensingInfos" : [ { + "licenseId" : "LicenseRef-custom", + "extractedText" : "A custom license that is not on the SPDX list.", + "name" : "Custom Test License", + "seeAlsos" : [ "https://example.com/custom-license" ] + } ], + "packages" : [ + { + "SPDXID" : "SPDXRef-Package-test-app", + "downloadLocation" : "NOASSERTION", + "filesAnalyzed" : false, + "name" : "test-app", + "primaryPackagePurpose" : "APPLICATION", + "supplier" : "NOASSERTION", + "versionInfo" : "1.0.0" + }, + { + "SPDXID" : "SPDXRef-Package-skip-kit", + "copyrightText" : "Copyright 2025 Skip", + "downloadLocation" : "https://source.skip.tools/skip-kit.git", + "externalRefs" : [ + { + "referenceCategory" : "PACKAGE-MANAGER", + "referenceLocator" : "https://source.skip.tools/skip-kit.git", + "referenceType" : "swiftpm" + } + ], + "filesAnalyzed" : false, + "licenseConcluded" : "MPL-2.0", + "licenseDeclared" : "MPL-2.0", + "name" : "skip-kit", + "primaryPackagePurpose" : "LIBRARY", + "supplier" : "Organization: Skip", + "versionInfo" : "1.0.0" + }, + { + "SPDXID" : "SPDXRef-Package-example-eupl", + "checksums" : [ + { + "algorithm" : "SHA1", + "checksumValue" : "0123456789abcdef0123456789abcdef01234567" + }, + { + "algorithm" : "SHA256", + "checksumValue" : "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" + } + ], + "copyrightText" : "NOASSERTION", + "downloadLocation" : "https://example.com/example-eupl.tar.gz", + "externalRefs" : [ + { + "referenceCategory" : "PACKAGE-MANAGER", + "referenceLocator" : "pkg:generic/example-eupl@1.2.3", + "referenceType" : "purl" + } + ], + "filesAnalyzed" : false, + "licenseConcluded" : "EUPL-1.2", + "licenseDeclared" : "EUPL-1.2", + "name" : "example-eupl", + "primaryPackagePurpose" : "LIBRARY", + "supplier" : "Person: Example Author", + "versionInfo" : "1.2.3" + }, + { + "SPDXID" : "SPDXRef-Package-example-eupl-helper", + "copyrightText" : "NOASSERTION", + "downloadLocation" : "https://example.com/example-eupl-helper.tar.gz", + "filesAnalyzed" : false, + "licenseConcluded" : "EUPL-1.2", + "licenseDeclared" : "EUPL-1.2", + "name" : "example-eupl-helper", + "primaryPackagePurpose" : "LIBRARY", + "supplier" : "Person: Example Author", + "versionInfo" : "0.9.0" + }, + { + "SPDXID" : "SPDXRef-Package-example-custom", + "copyrightText" : "NOASSERTION", + "downloadLocation" : "https://example.com/example-custom.tar.gz", + "filesAnalyzed" : false, + "licenseConcluded" : "NOASSERTION", + "licenseDeclared" : "LicenseRef-custom", + "name" : "example-custom", + "primaryPackagePurpose" : "LIBRARY", + "supplier" : "Organization: Example Inc", + "versionInfo" : "0.1.0" + } + ], + "relationships" : [ + { + "relatedSpdxElement" : "SPDXRef-Package-test-app", + "relationshipType" : "DESCRIBES", + "spdxElementId" : "SPDXRef-DOCUMENT" + }, + { + "relatedSpdxElement" : "SPDXRef-Package-skip-kit", + "relationshipType" : "DEPENDS_ON", + "spdxElementId" : "SPDXRef-Package-test-app" + }, + { + "relatedSpdxElement" : "SPDXRef-Package-example-eupl", + "relationshipType" : "DEPENDS_ON", + "spdxElementId" : "SPDXRef-Package-test-app" + }, + { + "relatedSpdxElement" : "SPDXRef-Package-example-custom", + "relationshipType" : "DEPENDS_ON", + "spdxElementId" : "SPDXRef-Package-test-app" + }, + { + "relatedSpdxElement" : "SPDXRef-Package-example-eupl-helper", + "relationshipType" : "DEPENDS_ON", + "spdxElementId" : "SPDXRef-Package-example-eupl" + } + ] +} diff --git a/Tests/SkipKitTests/SkipKitTests.swift b/Tests/SkipKitTests/SkipKitTests.swift index 5a33e5d..a035d37 100644 --- a/Tests/SkipKitTests/SkipKitTests.swift +++ b/Tests/SkipKitTests/SkipKitTests.swift @@ -284,4 +284,207 @@ final class SkipKitTests: XCTestCase { XCTAssertEqual(NetworkStatus.wifi.rawValue, "wifi") XCTAssertEqual(NetworkStatus.offline.rawValue, "offline") } + + // MARK: - SBOM Tests + + /// A simple error type for missing test resources. + private struct SBOMTestResourceMissing: Error {} + + /// Loads the bundled `sbom-test.spdx.json` resource as raw `Data`. + private func loadSBOMTestData() throws -> Data { + guard let url = Bundle.module.url(forResource: "sbom-test.spdx", withExtension: "json") else { + XCTFail("Could not find sbom-test.spdx.json resource in test bundle") + throw SBOMTestResourceMissing() + } + return try Data(contentsOf: url) + } + + func testSBOMParseDocumentMetadata() throws { + let data = try loadSBOMTestData() + let doc = try SBOMDocument.parse(data: data) + XCTAssertEqual(doc.spdxVersion, "SPDX-2.3") + XCTAssertEqual(doc.SPDXID, "SPDXRef-DOCUMENT") + XCTAssertEqual(doc.dataLicense, "CC0-1.0") + XCTAssertEqual(doc.name, "skip-kit-sbom-test") + XCTAssertEqual(doc.documentNamespace, "https://skip.dev/spdx/skip-kit-test") + } + + func testSBOMParseCreationInfo() throws { + let doc = try SBOMDocument.parse(data: try loadSBOMTestData()) + XCTAssertEqual(doc.creationInfo?.created, "2026-04-08T12:00:00Z") + XCTAssertEqual(doc.creationInfo?.creators?.count, 1) + XCTAssertEqual(doc.creationInfo?.creators?.first, "Tool: skip-kit-test") + XCTAssertEqual(doc.creationInfo?.licenseListVersion, "3.22") + } + + func testSBOMParsePackages() throws { + let doc = try SBOMDocument.parse(data: try loadSBOMTestData()) + XCTAssertEqual(doc.packages.count, 5) + + // Find the skip-kit library package + let skipKitPackage = doc.packages.first { $0.name == "skip-kit" } + XCTAssertNotNil(skipKitPackage) + XCTAssertEqual(skipKitPackage?.versionInfo, "1.0.0") + XCTAssertEqual(skipKitPackage?.supplier, "Organization: Skip") + XCTAssertEqual(skipKitPackage?.licenseDeclared, "MPL-2.0") + XCTAssertEqual(skipKitPackage?.licenseConcluded, "MPL-2.0") + XCTAssertEqual(skipKitPackage?.copyrightText, "Copyright 2025 Skip") + XCTAssertEqual(skipKitPackage?.primaryPackagePurpose, "LIBRARY") + XCTAssertEqual(skipKitPackage?.downloadLocation, "https://source.skip.tools/skip-kit.git") + XCTAssertEqual(skipKitPackage?.filesAnalyzed, false) + XCTAssertEqual(skipKitPackage?.externalRefs?.count, 1) + XCTAssertEqual(skipKitPackage?.externalRefs?.first?.referenceType, "swiftpm") + XCTAssertEqual(skipKitPackage?.externalRefs?.first?.referenceCategory, "PACKAGE-MANAGER") + } + + func testSBOMParseChecksums() throws { + let doc = try SBOMDocument.parse(data: try loadSBOMTestData()) + let euplPackage = doc.packages.first { $0.name == "example-eupl" } + XCTAssertNotNil(euplPackage) + XCTAssertEqual(euplPackage?.checksums?.count, 2) + let sha1 = euplPackage?.checksums?.first { $0.algorithm == "SHA1" } + XCTAssertEqual(sha1?.checksumValue, "0123456789abcdef0123456789abcdef01234567") + let sha256 = euplPackage?.checksums?.first { $0.algorithm == "SHA256" } + XCTAssertEqual(sha256?.checksumValue, "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210") + } + + func testSBOMParseRelationships() throws { + let doc = try SBOMDocument.parse(data: try loadSBOMTestData()) + XCTAssertEqual(doc.relationships?.count, 5) + let describes = doc.relationships?.first { $0.relationshipType == "DESCRIBES" } + XCTAssertEqual(describes?.spdxElementId, "SPDXRef-DOCUMENT") + XCTAssertEqual(describes?.relatedSpdxElement, "SPDXRef-Package-test-app") + } + + func testSBOMParseExtractedLicenses() throws { + let doc = try SBOMDocument.parse(data: try loadSBOMTestData()) + XCTAssertEqual(doc.hasExtractedLicensingInfos?.count, 1) + let extracted = doc.extractedLicense(forId: "LicenseRef-custom") + XCTAssertNotNil(extracted) + XCTAssertEqual(extracted?.name, "Custom Test License") + XCTAssertEqual(extracted?.extractedText, "A custom license that is not on the SPDX list.") + XCTAssertEqual(extracted?.seeAlsos?.count, 1) + XCTAssertEqual(extracted?.seeAlsos?.first, "https://example.com/custom-license") + } + + func testSBOMDependencyPackagesFiltersApplication() throws { + let doc = try SBOMDocument.parse(data: try loadSBOMTestData()) + // The "test-app" package has APPLICATION purpose and should be filtered out. + let deps = doc.dependencyPackages + XCTAssertEqual(deps.count, 4) + XCTAssertFalse(deps.contains { $0.name == "test-app" }) + XCTAssertTrue(deps.contains { $0.name == "skip-kit" }) + XCTAssertTrue(deps.contains { $0.name == "example-eupl" }) + XCTAssertTrue(deps.contains { $0.name == "example-eupl-helper" }) + XCTAssertTrue(deps.contains { $0.name == "example-custom" }) + } + + func testSBOMDependencyPackagesSortedByName() throws { + let doc = try SBOMDocument.parse(data: try loadSBOMTestData()) + let names = doc.dependencyPackages.compactMap { $0.name } + // Expect alphabetical (case-insensitive) order. + XCTAssertEqual(names, ["example-custom", "example-eupl", "example-eupl-helper", "skip-kit"]) + } + + func testSBOMRootPackageSPDXID() throws { + let doc = try SBOMDocument.parse(data: try loadSBOMTestData()) + XCTAssertEqual(doc.rootPackageSPDXID, "SPDXRef-Package-test-app") + } + + func testSBOMTopLevelPackages() throws { + let doc = try SBOMDocument.parse(data: try loadSBOMTestData()) + let top = doc.topLevelPackages + // The root depends on skip-kit, example-eupl, and example-custom directly. + // example-eupl-helper is transitive (depended on by example-eupl) and should NOT + // appear in the top-level list. + let names = top.compactMap { $0.name } + XCTAssertEqual(names, ["example-custom", "example-eupl", "skip-kit"]) + XCTAssertFalse(names.contains("example-eupl-helper")) + } + + func testSBOMDirectDependencies() throws { + let doc = try SBOMDocument.parse(data: try loadSBOMTestData()) + guard let euplPackage = doc.packages.first(where: { $0.name == "example-eupl" }) else { + XCTFail("Expected to find example-eupl package") + return + } + let subDeps = doc.directDependencies(of: euplPackage) + XCTAssertEqual(subDeps.count, 1) + XCTAssertEqual(subDeps.first?.name, "example-eupl-helper") + + // skip-kit has no transitive dependencies in the test fixture. + guard let skipKitPackage = doc.packages.first(where: { $0.name == "skip-kit" }) else { + XCTFail("Expected to find skip-kit package") + return + } + XCTAssertTrue(doc.directDependencies(of: skipKitPackage).isEmpty) + } + + func testSBOMDirectDependenciesSorted() throws { + let doc = try SBOMDocument.parse(data: try loadSBOMTestData()) + // The root package's direct dependencies should be sorted by name. + let rootDeps = doc.directDependencies(ofSPDXID: "SPDXRef-Package-test-app") + let names = rootDeps.compactMap { $0.name } + XCTAssertEqual(names, ["example-custom", "example-eupl", "skip-kit"]) + } + + func testSBOMDisplayMode() throws { + // Hierarchy is the default. + XCTAssertEqual(SBOMDisplayMode.hierarchy.rawValue, "hierarchy") + XCTAssertEqual(SBOMDisplayMode.flat.rawValue, "flat") + } + + func testSBOMSPDXLicenseIsUnknown() throws { + XCTAssertTrue(SPDXLicense.isUnknown(nil)) + XCTAssertTrue(SPDXLicense.isUnknown("")) + XCTAssertTrue(SPDXLicense.isUnknown("NOASSERTION")) + XCTAssertTrue(SPDXLicense.isUnknown("NONE")) + XCTAssertFalse(SPDXLicense.isUnknown("MIT")) + XCTAssertFalse(SPDXLicense.isUnknown("EUPL-1.2")) + } + + func testSBOMSPDXCanonicalIdentifier() throws { + XCTAssertEqual(SPDXLicense.canonicalIdentifier("MIT"), "MIT") + XCTAssertEqual(SPDXLicense.canonicalIdentifier("EUPL-1.2"), "EUPL-1.2") + XCTAssertEqual(SPDXLicense.canonicalIdentifier("Apache-2.0"), "Apache-2.0") + // Compound expressions should yield just the head identifier. + XCTAssertEqual(SPDXLicense.canonicalIdentifier("MIT OR Apache-2.0"), "MIT") + XCTAssertEqual(SPDXLicense.canonicalIdentifier("LGPL-3.0-only WITH LGPL-3.0-linking-exception"), "LGPL-3.0-only") + // NOASSERTION yields nil. + XCTAssertNil(SPDXLicense.canonicalIdentifier("NOASSERTION")) + XCTAssertNil(SPDXLicense.canonicalIdentifier(nil)) + // LicenseRef- IDs are not on spdx.org/licenses and should yield nil. + XCTAssertNil(SPDXLicense.canonicalIdentifier("LicenseRef-custom")) + } + + func testSBOMSPDXLicensePageURL() throws { + XCTAssertEqual(SPDXLicense.licensePageURL(for: "EUPL-1.2")?.absoluteString, "https://spdx.org/licenses/EUPL-1.2.html") + XCTAssertEqual(SPDXLicense.licensePageURL(for: "MPL-2.0")?.absoluteString, "https://spdx.org/licenses/MPL-2.0.html") + XCTAssertEqual(SPDXLicense.licensePageURL(for: "Apache-2.0")?.absoluteString, "https://spdx.org/licenses/Apache-2.0.html") + // Compound expressions should still resolve to the head license's page. + XCTAssertEqual(SPDXLicense.licensePageURL(for: "LGPL-3.0-only WITH LGPL-3.0-linking-exception")?.absoluteString, "https://spdx.org/licenses/LGPL-3.0-only.html") + XCTAssertNil(SPDXLicense.licensePageURL(for: "NOASSERTION")) + XCTAssertNil(SPDXLicense.licensePageURL(for: nil)) + XCTAssertNil(SPDXLicense.licensePageURL(for: "LicenseRef-custom")) + } + + func testSBOMResourceNames() throws { + XCTAssertEqual(sbomDarwinResourceName, "sbom-darwin-ios.spdx") + XCTAssertEqual(sbomLinuxAndroidResourceName, "sbom-linux-android.spdx") + XCTAssertEqual(sbomResourceExtension, "json") + // Default resource name is platform-specific. + #if os(Android) + XCTAssertEqual(SBOMDocument.defaultResourceName, sbomLinuxAndroidResourceName) + #else + XCTAssertEqual(SBOMDocument.defaultResourceName, sbomDarwinResourceName) + #endif + } + + func testSBOMBundleContainsSBOM() throws { + // The test bundle does not contain a sbom-darwin-ios.spdx.json or sbom-linux-android.spdx.json, + // so this should return false. (We deliberately use a different name for the test fixture so + // that we can also exercise the negative case.) + XCTAssertFalse(SBOMDocument.bundleContainsSBOM(Bundle.module)) + XCTAssertNil(try SBOMDocument.load(from: Bundle.module)) + } }