diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 030c27f0..73ba6175 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,7 +96,7 @@ jobs: android-ci: needs: build-plugin - timeout-minutes: 90 + timeout-minutes: 120 runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -165,6 +165,16 @@ jobs: - run: skip doctor + - name: "Create and export new app" + if: runner.os != 'Linux' + working-directory: ${{ runner.temp }} + run: | + skip init --transpiled-app --free --appid some.app.id some-app SomeApp SomeAppModel + # verify that the project itself is free + skip verify --sbom --free --project some-app + # perform an export of the app + skip export --sbom --show-tree --project some-app + # disables to speed up CI #- name: "Create and export new Skip Lite app project" # if: runner.os != 'Linux' diff --git a/Sources/SkipBuild/Commands/ExportCommand.swift b/Sources/SkipBuild/Commands/ExportCommand.swift index b56fe5c2..2b0e9da6 100644 --- a/Sources/SkipBuild/Commands/ExportCommand.swift +++ b/Sources/SkipBuild/Commands/ExportCommand.swift @@ -93,6 +93,9 @@ Build and export the Skip modules defined in the Package.swift, with libraries e @Option(help: ArgumentHelp("Destination architectures for native libraries", valueName: "arch")) var arch: [AndroidArchArgument] = [] + @Flag(help: ArgumentHelp("Generate SPDX SBOM files alongside export artifacts")) + var sbom: Bool = false + func performCommand(with out: MessageQueue) async { await withLogStream(with: out) { try await runExport(with: out) @@ -318,6 +321,22 @@ Build and export the Skip modules defined in the Package.swift, with libraries e try fs.removeFileTree(projectOutputBaseFolder) // only export the zip file; remove the sources } + // Generate SBOM files if requested + if self.sbom { + let projectURL = URL(fileURLWithPath: self.project).standardized + let sbomFiles = try await SBOMGenerator.generateSBOMFiles( + generateIOS: self.ios, + generateAndroid: self.android, + projectPath: projectURL.path, + packageName: packageName, + packageJSON: packageJSON, + outputDirAbsolute: outputFolderAbsolute, + command: self, + out: out + ) + createdURLs.append(contentsOf: sbomFiles) + } + let outputFolderTitle = outputFolder.abbreviatingWithTilde await out.write(status: .pass, "Skip export \(packageName) to \(outputFolderTitle) (\(startTime.timingSecondsSinceNow))") diff --git a/Sources/SkipBuild/Commands/SBOMCommand.swift b/Sources/SkipBuild/Commands/SBOMCommand.swift new file mode 100644 index 00000000..50d50571 --- /dev/null +++ b/Sources/SkipBuild/Commands/SBOMCommand.swift @@ -0,0 +1,1009 @@ +// Copyright (c) 2023 - 2026 Skip +// Licensed under the GNU Affero General Public License v3.0 +// SPDX-License-Identifier: AGPL-3.0-only + +import Foundation +import ArgumentParser +import SkipSyntax +import TSCBasic +#if canImport(SkipDriveExternal) +import SkipDriveExternal + +extension SBOMCreateCommand : GradleHarness { } +extension SBOMValidateCommand : GradleHarness { } +extension SBOMVerifyCommand : GradleHarness { } +extension VerifyCommand : GradleHarness { } +fileprivate let sbomCommandEnabled = true +#else +fileprivate let sbomCommandEnabled = false +#endif + +// MARK: - Container Command + +@available(macOS 13, iOS 16, tvOS 16, watchOS 8, *) +struct SBOMCommand: AsyncParsableCommand { + static var configuration = CommandConfiguration( + commandName: "sbom", + abstract: "Generate and validate SPDX SBOM files for iOS and Android", + discussion: """ + Commands for generating and validating Software Bill of Materials (SBOM) \ + in SPDX JSON format for Skip app projects. + """, + shouldDisplay: sbomCommandEnabled, + subcommands: [ + SBOMCreateCommand.self, + SBOMValidateCommand.self, + SBOMVerifyCommand.self, + ]) +} + +// MARK: - Create Subcommand + +@available(macOS 13, iOS 16, tvOS 16, watchOS 8, *) +struct SBOMCreateCommand: MessageCommand, ToolOptionsCommand { + static var configuration = CommandConfiguration( + commandName: "create", + abstract: "Generate SPDX SBOM files for iOS and Android", + usage: """ +# generate SBOMs for both platforms +skip sbom create + +# generate SBOM for iOS only +skip sbom create --ios + +# generate SBOM for Android only +skip sbom create --android + +# specify output directory +skip sbom create -d ./output + +# link SBOMs into app Resources +skip sbom create --link-resource +""", + discussion: """ +Generate Software Bill of Materials (SBOM) in SPDX JSON format for a Skip app project. \ +By default, generates SBOMs for both iOS (sbom-darwin-ios.spdx.json) and Android (sbom-linux-android.spdx.json). \ +For iOS, dependencies are extracted from the SwiftPM Package.resolved file. \ +For Android, the spdx-gradle-plugin is used to analyze Gradle dependencies. +""", + shouldDisplay: sbomCommandEnabled) + + @Option(name: [.customShort("d"), .long], help: ArgumentHelp("Output directory for SBOM files", valueName: "directory")) + var dir: String? + + @OptionGroup(title: "Output Options") + var outputOptions: OutputOptions + + @OptionGroup(title: "Tool Options") + var toolOptions: ToolOptions + + @Option(help: ArgumentHelp("Project folder", valueName: "dir")) + var project: String = "." + + @Flag(help: ArgumentHelp("Generate SBOM for iOS only")) + var ios: Bool = false + + @Flag(help: ArgumentHelp("Generate SBOM for Android only")) + var android: Bool = false + + @Flag(name: .long, help: ArgumentHelp("Create symlinks from app Resources to generated SBOM files")) + var linkResource: Bool = false + + func performCommand(with out: MessageQueue) async { + await withLogStream(with: out) { + try await runSBOMCreate(with: out) + } + } + + func runSBOMCreate(with out: MessageQueue) async throws { + let startTime = Date.now + let fs = localFileSystem + + let generateIOS = ios || (!ios && !android) + let generateAndroid = android || (!ios && !android) + + let packageJSON = try await parseSwiftPackage(with: out, at: project) + let packageName = packageJSON.name + + let moduleNames = packageJSON.targets.compactMap(\.a).filter({ $0.type == "regular" }).filter({ $0.pluginUsages != nil }).map(\.name) + guard let appModuleName = moduleNames.first else { + throw error("No Skip module targets found in package \(packageName)") + } + + let projectURL = URL(fileURLWithPath: self.project).standardized + let resolvedProjectPath = projectURL.path + + let outputDir = self.dir ?? "." + let outputDirAbsolute = try AbsolutePath(validating: outputDir, relativeTo: fs.currentWorkingDirectory!) + try fs.createDirectory(outputDirAbsolute, recursive: true) + + let generatedFiles = try await generateSBOMFiles( + generateIOS: generateIOS, + generateAndroid: generateAndroid, + projectPath: resolvedProjectPath, + packageName: packageName, + packageJSON: packageJSON, + outputDirAbsolute: outputDirAbsolute, + out: out + ) + + // Create resource symlinks if requested + if linkResource { + let resourcesFolder = projectURL.appendingPathComponent("Sources/\(appModuleName)/Resources", isDirectory: true) + try FileManager.default.createDirectory(at: resourcesFolder, withIntermediateDirectories: true) + + for fileURL in generatedFiles { + let linkPath = resourcesFolder.appendingPathComponent(fileURL.lastPathComponent).path + try? FileManager.default.removeItem(atPath: linkPath) + // Create a relative symlink from the Resources folder to the SBOM file + let relativePath = relativePath(from: resourcesFolder.path, to: fileURL.path) + try FileManager.default.createSymbolicLink(atPath: linkPath, withDestinationPath: relativePath) + await out.write(status: .pass, "Linked \(fileURL.lastPathComponent) -> \(relativePath)") + } + } + + await out.write(status: .pass, "Skip SBOM create \(packageName) (\(startTime.timingSecondsSinceNow))") + } +} + +// MARK: - Validate Subcommand + +@available(macOS 13, iOS 16, tvOS 16, watchOS 8, *) +struct SBOMValidateCommand: MessageCommand, ToolOptionsCommand { + static var configuration = CommandConfiguration( + commandName: "validate", + abstract: "Validate existing SBOM files against the current project state", + usage: """ +# validate SBOMs in current directory +skip sbom validate + +# validate SBOMs in a specific directory +skip sbom validate -d ./output +""", + discussion: """ +Reads the existing SBOM files (sbom-darwin-ios.spdx.json and/or sbom-linux-android.spdx.json) \ +and verifies that their dependency lists match the current project state. Reports any packages \ +that have been added, removed, or changed version. +""", + shouldDisplay: sbomCommandEnabled) + + @Option(name: [.customShort("d"), .long], help: ArgumentHelp("Directory containing SBOM files to validate", valueName: "directory")) + var dir: String? + + @OptionGroup(title: "Output Options") + var outputOptions: OutputOptions + + @OptionGroup(title: "Tool Options") + var toolOptions: ToolOptions + + @Option(help: ArgumentHelp("Project folder", valueName: "dir")) + var project: String = "." + + func performCommand(with out: MessageQueue) async { + await withLogStream(with: out) { + try await runSBOMValidate(with: out) + } + } + + func runSBOMValidate(with out: MessageQueue) async throws { + let startTime = Date.now + let fs = localFileSystem + + let packageJSON = try await parseSwiftPackage(with: out, at: project) + let packageName = packageJSON.name + + let sbomDir = self.dir ?? "." + let sbomDirAbsolute = try AbsolutePath(validating: sbomDir, relativeTo: fs.currentWorkingDirectory!) + + let projectURL = URL(fileURLWithPath: self.project).standardized + let resolvedProjectPath = projectURL.path + + var hasErrors = false + + // Validate iOS SBOM + let iosFile = sbomDirAbsolute.appending(component: SBOMGenerator.iosFilename) + if fs.isFile(iosFile) { + let valid = try await validateIOSSBOM( + existingFile: iosFile, + projectPath: resolvedProjectPath, + packageName: packageName, + packageJSON: packageJSON, + out: out + ) + if !valid { hasErrors = true } + } else { + await out.write(status: .warn, "iOS SBOM not found at \(iosFile.pathString)") + } + + // Validate Android SBOM + let androidFile = sbomDirAbsolute.appending(component: SBOMGenerator.androidFilename) + if fs.isFile(androidFile) { + let valid = try await validateAndroidSBOM( + existingFile: androidFile, + projectPath: resolvedProjectPath, + packageName: packageName, + out: out + ) + if !valid { hasErrors = true } + } else { + await out.write(status: .warn, "Android SBOM not found at \(androidFile.pathString)") + } + + if hasErrors { + throw error("SBOM validation failed for \(packageName)") + } + await out.write(status: .pass, "Skip SBOM validate \(packageName) (\(startTime.timingSecondsSinceNow))") + } + + func validateIOSSBOM(existingFile: AbsolutePath, projectPath: String, packageName: String, packageJSON: PackageManifest, out: MessageQueue) async throws -> Bool { + let existingData = try Data(contentsOf: existingFile.asURL) + guard let existingDoc = try JSONSerialization.jsonObject(with: existingData) as? [String: Any], + let existingPackages = existingDoc["packages"] as? [[String: Any]] else { + await out.write(status: .fail, "iOS SBOM: invalid SPDX document format") + return false + } + + let freshDoc = try SBOMGenerator.generateIOSSBOM(projectPath: projectPath, packageName: packageName, packageJSON: packageJSON) + guard let freshPackages = freshDoc["packages"] as? [[String: Any]] else { + await out.write(status: .fail, "iOS SBOM: could not generate fresh SBOM for comparison") + return false + } + + return await validatePackageLists( + platform: "iOS", + existingPackages: existingPackages, + freshPackages: freshPackages, + out: out + ) + } + + func validateAndroidSBOM(existingFile: AbsolutePath, projectPath: String, packageName: String, out: MessageQueue) async throws -> Bool { + let existingData = try Data(contentsOf: existingFile.asURL) + guard let existingDoc = try JSONSerialization.jsonObject(with: existingData) as? [String: Any], + let existingPackages = existingDoc["packages"] as? [[String: Any]] else { + await out.write(status: .fail, "Android SBOM: invalid SPDX document format") + return false + } + + // Generate a fresh SBOM to a temp file via the spdx-gradle-plugin and compare + let fs = localFileSystem + let tempFile = try AbsolutePath(validating: NSTemporaryDirectory()).appending(component: "skip-sbom-validate-\(UUID().uuidString.prefix(8)).spdx.json") + defer { try? fs.removeFileTree(tempFile) } + + try await SBOMGenerator.generateAndroidSBOM(projectPath: projectPath, packageName: packageName, outputFile: tempFile, command: self, out: out) + + let freshData = try Data(contentsOf: tempFile.asURL) + guard let freshDoc = try JSONSerialization.jsonObject(with: freshData) as? [String: Any], + let freshPackages = freshDoc["packages"] as? [[String: Any]] else { + await out.write(status: .fail, "Android SBOM: could not generate fresh SBOM for comparison") + return false + } + + return await validatePackageLists( + platform: "Android", + existingPackages: existingPackages, + freshPackages: freshPackages, + out: out + ) + } + + func validatePackageLists(platform: String, existingPackages: [[String: Any]], freshPackages: [[String: Any]], out: MessageQueue) async -> Bool { + // Build maps of name -> version for comparison, excluding the root/app package. + // For iOS SBOMs: root has primaryPackagePurpose == "APPLICATION". + // For Android SBOMs (spdx-gradle-plugin): root is the app module with a simple name like "app". + // Dependencies have names containing ":" (e.g., "org.jetbrains.kotlin:kotlin-stdlib") + // or primaryPackagePurpose == "LIBRARY". + func packageMap(_ packages: [[String: Any]]) -> [String: String] { + var map: [String: String] = [:] + for pkg in packages { + guard let name = pkg["name"] as? String, + let version = pkg["versionInfo"] as? String else { continue } + let purpose = pkg["primaryPackagePurpose"] as? String + if purpose == "APPLICATION" { continue } + // Skip the DOCUMENT entry and root project entries that don't look like dependencies + if pkg["SPDXID"] as? String == "SPDXRef-DOCUMENT" { continue } + // For spdx-gradle-plugin output, the root app has a simple name without ":" + // while all Maven dependencies have "group:artifact" format + if purpose == nil && !name.contains(":") { continue } + map[name] = version + } + return map + } + + let existingMap = packageMap(existingPackages) + let freshMap = packageMap(freshPackages) + + var valid = true + + // Check for added dependencies + for (name, version) in freshMap where existingMap[name] == nil { + await out.write(status: .fail, "\(platform) SBOM: missing dependency \(name) \(version)") + valid = false + } + + // Check for removed dependencies + for (name, version) in existingMap where freshMap[name] == nil { + await out.write(status: .fail, "\(platform) SBOM: stale dependency \(name) \(version) (no longer present)") + valid = false + } + + // Check for version changes + for (name, freshVersion) in freshMap { + if let existingVersion = existingMap[name], existingVersion != freshVersion { + await out.write(status: .fail, "\(platform) SBOM: version mismatch for \(name): SBOM has \(existingVersion), project has \(freshVersion)") + valid = false + } + } + + if valid { + await out.write(status: .pass, "\(platform) SBOM validated: \(existingMap.count) dependencies match") + } + + return valid + } +} + +// MARK: - Verify Subcommand + +@available(macOS 13, iOS 16, tvOS 16, watchOS 8, *) +struct SBOMVerifyCommand: MessageCommand, ToolOptionsCommand { + static var configuration = CommandConfiguration( + commandName: "verify", + abstract: "Verify SBOM dependency licenses against a policy", + usage: """ +# allow only Apache-2.0 and MIT licenses +skip sbom verify --allow Apache-2.0 --allow MIT + +# deny GPL licenses +skip sbom verify --deny GPL-3.0-only --deny GPL-2.0-only + +# allow all FSF-compatible free/open-source licenses +skip sbom verify --free + +# verify only Android licenses using the concluded field +skip sbom verify --free --android --concluded + +# allow FLOSS but deny AGPL, and list specific NOASSERTION packages +skip sbom verify --free --deny AGPL-3.0-only --noassertion SPDXRef-gnrtd5 --noassertion SPDXRef-gnrtd12 +""", + discussion: """ +Verify that all dependency licenses in the SBOM files conform to a specified policy. \ +Use --allow to specify permitted SPDX license identifiers, or --deny to specify forbidden ones. \ +The --free flag permits a curated set of licenses recognized as free/open-source by the FSF. \ +By default, the licenseDeclared field is checked; use --concluded to check licenseConcluded instead. +""", + shouldDisplay: sbomCommandEnabled) + + @Option(name: [.customShort("d"), .long], help: ArgumentHelp("Directory containing SBOM files", valueName: "directory")) + var dir: String? + + @OptionGroup(title: "Output Options") + var outputOptions: OutputOptions + + @OptionGroup(title: "Tool Options") + var toolOptions: ToolOptions + + @Option(help: ArgumentHelp("Project folder", valueName: "dir")) + var project: String = "." + + @Option(parsing: .upToNextOption, help: ArgumentHelp("SPDX identifiers for allowed licenses", valueName: "SPDX-ID")) + var allow: [String] = [] + + @Option(parsing: .upToNextOption, help: ArgumentHelp("SPDX identifiers for denied licenses", valueName: "SPDX-ID")) + var deny: [String] = [] + + @Flag(help: ArgumentHelp("Allow all FSF-recognized free/open-source licenses")) + var free: Bool = false + + @Flag(help: ArgumentHelp("Verify iOS SBOM only")) + var ios: Bool = false + + @Flag(help: ArgumentHelp("Verify Android SBOM only")) + var android: Bool = false + + @Flag(help: ArgumentHelp("Check licenseDeclared field (default)")) + var declared: Bool = false + + @Flag(help: ArgumentHelp("Check licenseConcluded field instead of licenseDeclared")) + var concluded: Bool = false + + @Flag(name: .long, help: ArgumentHelp("Allow packages with NOASSERTION license (default)")) + var allowNoassertion: Bool = false + + @Flag(name: .long, help: ArgumentHelp("Deny packages with NOASSERTION license")) + var denyNoassertion: Bool = false + + @Option(parsing: .upToNextOption, help: ArgumentHelp("SPDX IDs of specific packages permitted to have NOASSERTION license (implies --deny-noassertion for unlisted packages)", valueName: "SPDXID")) + var noassertion: [String] = [] + + func performCommand(with out: MessageQueue) async { + await withLogStream(with: out) { + try await runSBOMVerify(with: out) + } + } + + func runSBOMVerify(with out: MessageQueue) async throws { + let startTime = Date.now + let fs = localFileSystem + + // Build the license policy + let policy = try buildPolicy() + + let checkIOS = ios || (!ios && !android) + let checkAndroid = android || (!ios && !android) + + // Determine the SBOM directory and whether files exist + let sbomDirAbsolute: AbsolutePath + var tempDir: AbsolutePath? = nil + + if let dir = self.dir { + sbomDirAbsolute = try AbsolutePath(validating: dir, relativeTo: fs.currentWorkingDirectory!) + } else { + // Check current directory first + let cwd = fs.currentWorkingDirectory! + let iosExists = !checkIOS || fs.isFile(cwd.appending(component: SBOMGenerator.iosFilename)) + let androidExists = !checkAndroid || fs.isFile(cwd.appending(component: SBOMGenerator.androidFilename)) + + if iosExists && androidExists { + sbomDirAbsolute = cwd + } else { + // No pre-existing SBOM files found; generate them to a temp directory + await out.write(status: .pass, "No SBOM files found, generating for verification") + + let projectURL = URL(fileURLWithPath: self.project).standardized + let resolvedProjectPath = projectURL.path + + let packageJSON = try await parseSwiftPackage(with: out, at: self.project) + + let tmp = try AbsolutePath(validating: NSTemporaryDirectory()).appending(component: "skip-sbom-verify-\(UUID().uuidString.prefix(8))") + try fs.createDirectory(tmp, recursive: true) + tempDir = tmp + + try await SBOMGenerator.generateSBOMFiles( + generateIOS: checkIOS, + generateAndroid: checkAndroid, + projectPath: resolvedProjectPath, + packageName: packageJSON.name, + packageJSON: packageJSON, + outputDirAbsolute: tmp, + command: self, + out: out + ) + + sbomDirAbsolute = tmp + } + } + + defer { if let tempDir = tempDir { try? fs.removeFileTree(tempDir) } } + + var totalChecked = 0 + var totalViolations = 0 + + if checkIOS { + let iosFile = sbomDirAbsolute.appending(component: SBOMGenerator.iosFilename) + if fs.isFile(iosFile) { + let (checked, violations) = try await verifyPlatform(file: iosFile, platform: "iOS", policy: policy, out: out) + totalChecked += checked + totalViolations += violations + } else { + await out.write(status: .warn, "iOS SBOM not found at \(iosFile.pathString)") + } + } + + if checkAndroid { + let androidFile = sbomDirAbsolute.appending(component: SBOMGenerator.androidFilename) + if fs.isFile(androidFile) { + let (checked, violations) = try await verifyPlatform(file: androidFile, platform: "Android", policy: policy, out: out) + totalChecked += checked + totalViolations += violations + } else { + await out.write(status: .warn, "Android SBOM not found at \(androidFile.pathString)") + } + } + + if totalViolations > 0 { + throw error("\(totalViolations) license \(totalViolations == 1 ? "violation" : "violations") found in \(totalChecked) packages") + } + await out.write(status: .pass, "Skip SBOM verify: \(totalChecked) packages checked, no violations (\(startTime.timingSecondsSinceNow))") + } + + // MARK: - Policy Construction + + typealias LicensePolicy = SBOMLicensePolicy + typealias NoassertionMode = SBOMNoassertionMode + + func buildPolicy() throws -> LicensePolicy { + // Determine the license field to check + let licenseField: String + if concluded { + licenseField = "licenseConcluded" + } else { + licenseField = "licenseDeclared" + } + + // Build the allowed set + var allowedLicenses: Set? = nil + if !allow.isEmpty || free { + var allowed = Set(allow) + if free { + allowed.formUnion(LicenseIdentification.flossLicenses) + } + allowedLicenses = allowed + } + + // Build the denied set + let deniedLicenses = Set(deny) + + // Determine NOASSERTION handling + let noassertionMode: NoassertionMode + if !noassertion.isEmpty { + // Explicit list of permitted NOASSERTION packages implies deny for unlisted ones + noassertionMode = .allowListed(Set(noassertion)) + } else if denyNoassertion { + noassertionMode = .deny + } else { + noassertionMode = .allow + } + + if allowedLicenses == nil && deniedLicenses.isEmpty { + throw error("Specify at least one of --allow, --deny, or --free to define a license policy") + } + + return LicensePolicy( + allowedLicenses: allowedLicenses, + deniedLicenses: deniedLicenses, + licenseField: licenseField, + noassertionMode: noassertionMode + ) + } + + // MARK: - Verification + + func verifyPlatform(file: AbsolutePath, platform: String, policy: LicensePolicy, out: MessageQueue) async throws -> (checked: Int, violations: Int) { + try await SBOMGenerator.verifySBOMPlatform(file: file, platform: platform, policy: policy, out: out) + } +} + +// MARK: - Shared SBOM Generation Logic + +/// Shared SBOM generation functions used by both `skip sbom create` and `skip export --sbom`. +@available(macOS 13, iOS 16, tvOS 16, watchOS 8, *) +enum SBOMGenerator { + static let iosFilename = "sbom-darwin-ios.spdx.json" + static let androidFilename = "sbom-linux-android.spdx.json" + + // MARK: - iOS SBOM Generation + + static func generateIOSSBOM(projectPath: String, packageName: String, packageJSON: PackageManifest) throws -> [String: Any] { + // Find Package.resolved - check workspace first, then project root + let resolvedPaths = [ + projectPath + "/Package.resolved", + projectPath + "/Project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + ] + + var resolvedData: Data? + var resolvedFilePath: String? + for path in resolvedPaths { + if let data = FileManager.default.contents(atPath: path) { + resolvedData = data + resolvedFilePath = path + break + } + } + + guard let data = resolvedData, let _ = resolvedFilePath else { + throw SBOMError(message: "Could not find or generate Package.resolved in \(projectPath)") + } + + let resolved = try JSONDecoder().decode(PackageResolved.self, from: data) + + let checkoutsDir = projectPath + "/.build/checkouts" + + var packages: [[String: Any]] = [] + var relationships: [[String: Any]] = [] + let documentNamespace = "https://skip.dev/spdx/\(packageName)/ios" + let rootSPDXID = "SPDXRef-Package-\(spdxSafeID(packageName))" + + // Root package + packages.append([ + "SPDXID": rootSPDXID, + "name": packageName, + "versionInfo": "source", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "supplier": "NOASSERTION", + "primaryPackagePurpose": "APPLICATION" + ]) + + relationships.append([ + "spdxElementId": "SPDXRef-DOCUMENT", + "relationshipType": "DESCRIBES", + "relatedSpdxElement": rootSPDXID + ]) + + for pin in resolved.pins { + let depSPDXID = "SPDXRef-Package-\(spdxSafeID(pin.identity))" + + var pkg: [String: Any] = [ + "SPDXID": depSPDXID, + "name": pin.identity, + "versionInfo": pin.state.version, + "downloadLocation": pin.location, + "filesAnalyzed": false, + "supplier": "NOASSERTION", + "primaryPackagePurpose": "LIBRARY" + ] + + pkg["externalRefs"] = [[ + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "swiftpm", + "referenceLocator": pin.location + ]] + + let checkoutPath = checkoutsDir + "/" + pin.identity + let license = LicenseIdentification.detectLicense(at: checkoutPath) + if let license = license { + pkg["licenseConcluded"] = license.spdxIdentifier + pkg["licenseDeclared"] = license.spdxIdentifier + } else { + pkg["licenseConcluded"] = "NOASSERTION" + pkg["licenseDeclared"] = "NOASSERTION" + } + pkg["copyrightText"] = "NOASSERTION" + + packages.append(pkg) + + relationships.append([ + "spdxElementId": rootSPDXID, + "relationshipType": "DEPENDS_ON", + "relatedSpdxElement": depSPDXID + ]) + } + + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withInternetDateTime] + let now = dateFormatter.string(from: Date()) + + return [ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "\(packageName)-ios", + "documentNamespace": documentNamespace, + "creationInfo": [ + "created": now, + "creators": ["Tool: skip-sbom"], + "licenseListVersion": "3.22" + ] as [String: Any], + "packages": packages, + "relationships": relationships + ] + } + + // MARK: - Android SBOM Generation + + /// Generate the Android SBOM by running the spdx-gradle-plugin on the project's Android build. + /// Uses a Gradle init script to inject the plugin into the existing Android project without + /// modifying any project files. + static func generateAndroidSBOM(projectPath: String, packageName: String, outputFile: AbsolutePath, command: C, out: MessageQueue) async throws { + let fs = localFileSystem + let env = ProcessInfo.processInfo.environmentWithDefaultToolPaths + + let androidFolder = projectPath + "/Android" + let androidFolderAbsolute = try AbsolutePath(validating: androidFolder) + + guard fs.isFile(androidFolderAbsolute.appending(component: "settings.gradle.kts")) else { + throw SBOMError(message: "Android project not found at \(androidFolder)") + } + + // Write a Groovy init script that injects the spdx-sbom plugin into the :app module. + // The script is idempotent — it skips projects that already have the plugin applied. + let initScript = try AbsolutePath(validating: NSTemporaryDirectory()).appending(component: "skip-spdx-init-\(UUID().uuidString.prefix(8)).gradle") + let initScriptContent = """ + initscript { + repositories { + mavenCentral() + gradlePluginPortal() + } + dependencies { + classpath 'org.spdx:spdx-gradle-plugin:+' + } + } + + allprojects { + afterEvaluate { project -> + if (project.plugins.hasPlugin('com.android.application') && !project.plugins.hasPlugin('org.spdx.sbom')) { + project.apply plugin: org.spdx.sbom.gradle.SpdxSbomPlugin + project.spdxSbom { + targets { + create("release") { + configurations.set(["releaseRuntimeClasspath"]) + } + } + } + } + } + } + """ + try initScriptContent.write(toFile: initScript.pathString, atomically: true, encoding: .utf8) + defer { try? fs.removeFileTree(initScript) } + + // Run spdxSbomForRelease on the real Android project with the init script + try await command.run(with: out, "Generate Android SBOM", ["gradle", ":app:spdxSbomForRelease", "--init-script", initScript.pathString, "--project-dir", androidFolderAbsolute.pathString, "--console=plain"], environment: env) + + // The spdx plugin writes output to {app.buildDir}/spdx/release.spdx.json. + // The Skip build plugin sets buildDir to {projectPath}/.build/Android/app + let buildFolder = try AbsolutePath(validating: projectPath + "/.build") + let spdxOutputDir = buildFolder.appending(components: ["Android", "app", "spdx"]) + + // Search for the generated .spdx.json file + guard fs.isDirectory(spdxOutputDir), + let spdxFiles = try? fs.getDirectoryContents(spdxOutputDir), + let spdxFileName = spdxFiles.first(where: { $0.hasSuffix(".spdx.json") }) else { + throw SBOMError(message: "Gradle SPDX plugin did not produce output in \(spdxOutputDir.pathString)") + } + + let spdxFile = spdxOutputDir.appending(component: spdxFileName) + try? fs.removeFileTree(outputFile) + try fs.copy(from: spdxFile, to: outputFile) + await out.write(status: .pass, "Generated Android SBOM: \(outputFile.basename)") + } + + // MARK: - High-Level Orchestration + + /// Generate SBOM files for the specified platforms, writing them to the output directory. + /// Returns the URLs of the generated files. Used by both `skip sbom create` and `skip export --sbom`. + @discardableResult + static func generateSBOMFiles(generateIOS: Bool, generateAndroid: Bool, projectPath: String, packageName: String, packageJSON: PackageManifest, outputDirAbsolute: AbsolutePath, command: C, out: MessageQueue) async throws -> [URL] { + let fs = localFileSystem + try fs.createDirectory(outputDirAbsolute, recursive: true) + var generatedFiles: [URL] = [] + + if generateIOS { + // Ensure SwiftPM dependencies are resolved so Package.resolved and .build/checkouts exist + let resolvedPaths = [ + projectPath + "/Package.resolved", + projectPath + "/Project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + ] + if !resolvedPaths.contains(where: { FileManager.default.fileExists(atPath: $0) }) { + try await command.run(with: out, "Resolve Swift package dependencies", ["swift", "package", "resolve", "--package-path", projectPath]) + } + + let iosFile = outputDirAbsolute.appending(component: iosFilename) + await command.outputOptions.monitor(with: out, "Generate iOS SBOM") { _ in + let sbom = try generateIOSSBOM(projectPath: projectPath, packageName: packageName, packageJSON: packageJSON) + let data = try JSONSerialization.data(withJSONObject: sbom, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) + try data.write(to: iosFile.asURL) + generatedFiles.append(iosFile.asURL) + return try iosFile.asURL.fileSizeString + } + } + + if generateAndroid { + let androidFile = outputDirAbsolute.appending(component: androidFilename) + try await generateAndroidSBOM(projectPath: projectPath, packageName: packageName, outputFile: androidFile, command: command, out: out) + generatedFiles.append(androidFile.asURL) + } + + return generatedFiles + } + + // MARK: - SBOM License Verification + + /// Verify SBOM licenses against a FLOSS policy, generating temporary SBOMs if needed. + /// Returns the number of violations found (0 = all pass). + @discardableResult + static func verifyFLOSSLicenses(projectPath: String, packageName: String, packageJSON: PackageManifest, command: C, out: MessageQueue) async throws -> Int { + let fs = localFileSystem + + // Generate SBOMs to a temp directory + let tempDir = try AbsolutePath(validating: NSTemporaryDirectory()).appending(component: "skip-sbom-verify-\(UUID().uuidString.prefix(8))") + try fs.createDirectory(tempDir, recursive: true) + defer { try? fs.removeFileTree(tempDir) } + + try await generateSBOMFiles( + generateIOS: true, + generateAndroid: true, + projectPath: projectPath, + packageName: packageName, + packageJSON: packageJSON, + outputDirAbsolute: tempDir, + command: command, + out: out + ) + + // Verify both platforms with FLOSS policy + var totalChecked = 0 + var totalViolations = 0 + + let policy = SBOMLicensePolicy( + allowedLicenses: LicenseIdentification.flossLicenses, + deniedLicenses: [], + licenseField: "licenseDeclared", + noassertionMode: .allow + ) + + for (filename, platform) in [(iosFilename, "iOS"), (androidFilename, "Android")] { + let file = tempDir.appending(component: filename) + guard fs.isFile(file) else { continue } + let (checked, violations) = try await verifySBOMPlatform(file: file, platform: platform, policy: policy, out: out) + totalChecked += checked + totalViolations += violations + } + + if totalViolations > 0 { + await out.write(status: .fail, "SBOM verify: \(totalViolations) license \(totalViolations == 1 ? "violation" : "violations") in \(totalChecked) packages") + } else if totalChecked > 0 { + await out.write(status: .pass, "SBOM verify: \(totalChecked) packages checked, all FLOSS-licensed") + } + + return totalViolations + } + + /// Verify the licenses in a single SPDX SBOM file against a policy. + static func verifySBOMPlatform(file: AbsolutePath, platform: String, policy: SBOMLicensePolicy, out: MessageQueue) async throws -> (checked: Int, violations: Int) { + let data = try Data(contentsOf: file.asURL) + guard let doc = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let packages = doc["packages"] as? [[String: Any]] else { + await out.write(status: .fail, "\(platform) SBOM: invalid SPDX document format") + return (0, 1) + } + + let licenseRefLookup = LicenseIdentification.buildLicenseRefLookup(from: doc) + let licenseRefNames = LicenseIdentification.buildLicenseRefNames(from: doc) + + func displayLicense(_ license: String) -> String { + guard license.contains("LicenseRef-") else { return license } + var result = license + for (ref, name) in licenseRefNames { + if result.contains(ref) { + result = result.replacingOccurrences(of: ref, with: "\(name) (\(ref))") + } + } + return result + } + + var checked = 0 + var violations = 0 + + for pkg in packages { + guard let name = pkg["name"] as? String, + let spdxID = pkg["SPDXID"] as? String else { continue } + + let purpose = pkg["primaryPackagePurpose"] as? String + if purpose == "APPLICATION" { continue } + if spdxID == "SPDXRef-DOCUMENT" { continue } + if purpose == nil && !name.contains(":") { continue } + + let version = pkg["versionInfo"] as? String ?? "?" + var license = pkg[policy.licenseField] as? String ?? "NOASSERTION" + + if license.contains("LicenseRef-") { + for (ref, resolved) in licenseRefLookup { + license = license.replacingOccurrences(of: ref, with: resolved) + } + } + + checked += 1 + + if license == "NOASSERTION" { + switch policy.noassertionMode { + case .allow: + await out.write(status: .pass, "\(platform): \(name) \(version) — NOASSERTION (allowed)") + case .deny: + await out.write(status: .fail, "\(platform): \(name) \(version) — NOASSERTION [\(spdxID)]") + violations += 1 + case .allowListed(let permitted): + if permitted.contains(spdxID) { + await out.write(status: .pass, "\(platform): \(name) \(version) — NOASSERTION (permitted [\(spdxID)])") + } else { + await out.write(status: .fail, "\(platform): \(name) \(version) — NOASSERTION (not in permitted list) [\(spdxID)]") + violations += 1 + } + } + continue + } + + let licenseComponents = LicenseIdentification.parseSPDXExpression(license) + + var denied = false + for component in licenseComponents { + if policy.deniedLicenses.contains(component) { + let detail = licenseComponents.count > 1 ? " (in \(displayLicense(license)))" : "" + await out.write(status: .fail, "\(platform): \(name) \(version) — \(displayLicense(component))\(detail) (denied)") + violations += 1 + denied = true + break + } + } + if denied { continue } + + if let allowed = policy.allowedLicenses { + let disallowed = licenseComponents.filter { !allowed.contains($0) } + if !disallowed.isEmpty { + let label = disallowed.count == 1 ? displayLicense(disallowed[0]) : displayLicense(license) + await out.write(status: .fail, "\(platform): \(name) \(version) — \(label) (not allowed)") + violations += 1 + continue + } + } + + await out.write(status: .pass, "\(platform): \(name) \(version) — \(displayLicense(license))") + } + + let summary = violations == 0 ? "all pass" : "\(violations) \(violations == 1 ? "violation" : "violations")" + await out.write(status: violations == 0 ? .pass : .fail, "\(platform) SBOM: \(checked) packages verified (\(summary))") + + return (checked, violations) + } + + // MARK: - Helpers + + static func spdxSafeID(_ input: String) -> String { + input.map { c in + if c.isLetter || c.isNumber || c == "." || c == "-" { + return String(c) + } else { + return "-" + } + }.joined() + } +} + +/// Compute a relative path from a directory to a target file path. +/// Both paths should be absolute. The result is suitable for use as a symlink destination. +private func relativePath(from fromDir: String, to toPath: String) -> String { + let fromComponents = URL(fileURLWithPath: fromDir).standardized.pathComponents + let toComponents = URL(fileURLWithPath: toPath).standardized.pathComponents + + // Find the common prefix length + var commonLength = 0 + while commonLength < fromComponents.count && commonLength < toComponents.count + && fromComponents[commonLength] == toComponents[commonLength] { + commonLength += 1 + } + + // Number of ".." needed to go up from fromDir to the common ancestor + let ups = fromComponents.count - commonLength + var parts = Array(repeating: "..", count: ups) + // Append the remaining path components from toPath + parts.append(contentsOf: toComponents[commonLength...]) + return parts.joined(separator: "/") +} + +/// Shared license policy for SBOM verification, used by both `skip sbom verify` and `skip verify --sbom`. +struct SBOMLicensePolicy { + let allowedLicenses: Set? // nil means no allowlist (all allowed unless denied) + let deniedLicenses: Set + let licenseField: String // "licenseDeclared" or "licenseConcluded" + let noassertionMode: SBOMNoassertionMode +} + +enum SBOMNoassertionMode { + case allow + case deny + case allowListed(Set) +} + +struct SBOMError: LocalizedError { + let message: String + var errorDescription: String? { message } +} + +// MARK: - Convenience for SBOMCreateCommand to call shared code + +@available(macOS 13, iOS 16, tvOS 16, watchOS 8, *) +extension SBOMCreateCommand { + func generateSBOMFiles(generateIOS: Bool, generateAndroid: Bool, projectPath: String, packageName: String, packageJSON: PackageManifest, outputDirAbsolute: AbsolutePath, out: MessageQueue) async throws -> [URL] { + try await SBOMGenerator.generateSBOMFiles( + generateIOS: generateIOS, + generateAndroid: generateAndroid, + projectPath: projectPath, + packageName: packageName, + packageJSON: packageJSON, + outputDirAbsolute: outputDirAbsolute, + command: self, + out: out + ) + } +} diff --git a/Sources/SkipBuild/Commands/VerifyCommand.swift b/Sources/SkipBuild/Commands/VerifyCommand.swift index 59830aa0..1e024c1b 100644 --- a/Sources/SkipBuild/Commands/VerifyCommand.swift +++ b/Sources/SkipBuild/Commands/VerifyCommand.swift @@ -5,7 +5,7 @@ import Foundation import ArgumentParser import Universal -//import TSCUtility +import TSCBasic @available(macOS 13, iOS 16, tvOS 16, watchOS 8, *) struct VerifyCommand: SkipCommand, StreamingCommand, ProjectCommand, ToolOptionsCommand { @@ -49,9 +49,100 @@ struct VerifyCommand: SkipCommand, StreamingCommand, ProjectCommand, ToolOptions @Flag(inversion: .prefixedNo, help: ArgumentHelp("Attempt to automatically fix issues")) var fix: Bool = false + @Flag(help: ArgumentHelp("Verify SBOM dependency licenses (uses FLOSS policy when --free is set)")) + var sbom: Bool = false + + @Flag(inversion: .prefixedNo, help: ArgumentHelp("Verify source file license headers match the project license")) + var licenses: Bool? = nil + func performCommand(with out: MessageQueue) async { await withLogStream(with: out) { try await performVerifyCommand(project: project, autofix: fix, free: free, fastlane: fastlane, with: out) + + if sbom { + let projectURL = URL(fileURLWithPath: project).standardized + let packageJSON = try await parseSwiftPackage(with: out, at: project) + + if free == true { + let violations = try await SBOMGenerator.verifyFLOSSLicenses( + projectPath: projectURL.path, + packageName: packageJSON.name, + packageJSON: packageJSON, + command: self, + out: out + ) + + if violations > 0 { + throw error("SBOM license verification failed") + } + } + } + + // Verify source file license headers when --licenses is specified, + // or auto-detect when --free is set and a license file is present + let projectURL = URL(fileURLWithPath: project).standardized + let checkLicenses = licenses ?? (free == true) + if checkLicenses { + try await verifySourceLicenseHeaders(projectPath: projectURL.path, out: out) + } + } + } + + /// Detect the project's license and verify that all source files have matching SPDX headers. + private func verifySourceLicenseHeaders(projectPath: String, out: MessageQueue) async throws { + // Detect the project license from license files in the project root + guard let projectLicense = LicenseIdentification.detectLicense(at: projectPath) else { + await out.write(status: .warn, "License headers: no license file found in project root, skipping header check") + return + } + + let expectedIdentifier = projectLicense.spdxIdentifier + await out.write(status: .pass, "License headers: project license is \(expectedIdentifier)") + + let fm = FileManager.default + let sourcesPath = projectPath + "/Sources" + guard fm.fileExists(atPath: sourcesPath) else { + await out.write(status: .warn, "License headers: no Sources/ directory found") + return + } + + // Collect all Swift source files under Sources/ + guard let enumerator = fm.enumerator(atPath: sourcesPath) else { return } + + var checked = 0 + var violations = 0 + + while let relativePath = enumerator.nextObject() as? String { + guard relativePath.hasSuffix(".swift") else { continue } + + let filePath = sourcesPath + "/" + relativePath + guard let content = try? String(contentsOfFile: filePath, encoding: .utf8) else { continue } + + checked += 1 + + // Check the first ~20 lines for an SPDX-License-Identifier header + let headerLines = content.components(separatedBy: .newlines).prefix(20) + let spdxIdentifier = headerLines.compactMap { line -> String? in + guard let range = line.range(of: "SPDX-License-Identifier:", options: .caseInsensitive) else { return nil } + let id = line[range.upperBound...].trimmingCharacters(in: .whitespaces) + return id.isEmpty ? nil : id + }.first + + if let spdxIdentifier = spdxIdentifier { + if !LicenseIdentification.areCompatible(spdxIdentifier, expectedIdentifier) { + await out.write(status: .fail, "License headers: \(relativePath) has \(spdxIdentifier), expected \(expectedIdentifier)") + violations += 1 + } + } else { + await out.write(status: .fail, "License headers: \(relativePath) missing SPDX-License-Identifier header") + violations += 1 + } + } + + if violations == 0 && checked > 0 { + await out.write(status: .pass, "License headers: \(checked) source files verified") + } else if violations > 0 { + throw error("\(violations) source file\(violations == 1 ? "" : "s") with incorrect or missing license headers") } } } @@ -120,12 +211,9 @@ extension ToolOptionsCommand where Self : StreamingCommand { let packageJSON = try await parseSwiftPackage(with: out, at: projectPath) let packageName = packageJSON.name - guard var moduleName = packageJSON.products.first?.name else { + guard let moduleName = packageJSON.products.first?.name else { throw AppVerifyError(errorDescription: "No products declared in package \(packageName) at \(projectPath)") } - if moduleName.hasSuffix("App") { - moduleName = moduleName.dropLast(3).description - } //let project = try FrameworkProjectLayout(root: projectFolderURL) //let sourcesDir = URL(fileURLWithPath: "Sources", isDirectory: true, relativeTo: projectFolderURL) diff --git a/Sources/SkipBuild/SkipCommand.swift b/Sources/SkipBuild/SkipCommand.swift index f7db769c..46772ad9 100644 --- a/Sources/SkipBuild/SkipCommand.swift +++ b/Sources/SkipBuild/SkipCommand.swift @@ -84,6 +84,7 @@ public struct SkipRunnerExecutor: SkipCommandExecutor { ADBCommand.self, AndroidCommand.self, ExportCommand.self, + SBOMCommand.self, DevicesCommand.self, TestCommand.self, diff --git a/Sources/SkipBuild/SourceLicense.swift b/Sources/SkipBuild/SourceLicense.swift index 290a9fd5..67838473 100644 --- a/Sources/SkipBuild/SourceLicense.swift +++ b/Sources/SkipBuild/SourceLicense.swift @@ -10,9 +10,9 @@ extension ProjectOptionValues { if self.free { if app { if self.appfair { - return .gpl3 + return .gpl2 } else { - return .gpl3 + return .gpl2 } } else { return .mpl2 @@ -73,6 +73,507 @@ enum SourceLicense: Equatable, CaseIterable { } } +// MARK: - License Identification + +/// Shared license identification and matching utilities used by SBOM generation and verification. +enum LicenseIdentification { + + // MARK: - Detected License + + struct DetectedLicense { + let spdxIdentifier: String + let name: String + } + + // MARK: - File-Based License Detection (for SwiftPM checkouts) + + /// Well-known license file names to search for in a package checkout directory. + private static let licenseFileNames = [ + "LICENSE", "LICENSE.txt", "LICENSE.TXT", "LICENSE.md", "LICENSE.MD", + "License", "License.txt", "License.TXT", "License.md", "License.MD", + "license", "license.txt", "license.TXT", "license.md", "license.MD", + "LICENCE", "LICENCE.txt", "LICENCE.TXT", "LICENCE.md", "LICENCE.MD", + "Licence", "Licence.txt", "Licence.md", + "licence", "licence.txt", "licence.md", + "COPYING", "COPYING.txt", "COPYING.md", + "copying", "copying.txt", + "LICENSE-MIT", "LICENSE-APACHE", "LICENSE.rst", "LICENSE.RST", + "LICENSE-MIT.txt", "LICENSE-APACHE.txt", + "MIT-LICENSE", "MIT-LICENSE.txt", + "APACHE-LICENSE", "APACHE-LICENSE.txt", + "LICENSE.LGPL", "LICENSE.GPL", "LICENSE.AGPL", + "LICENSE.MIT", "LICENSE.APACHE", "LICENSE.BSD", + "LICENSE.MPL", + ] + + /// Detect the license of a package by scanning for license files in the given directory. + static func detectLicense(at path: String) -> DetectedLicense? { + let fm = FileManager.default + guard fm.fileExists(atPath: path) else { return nil } + + for name in licenseFileNames { + let filePath = (path as NSString).appendingPathComponent(name) + guard let content = try? String(contentsOfFile: filePath, encoding: .utf8) else { + continue + } + if let license = identifyLicenseFromContent(content) { + return license + } + } + + let packageSwiftPath = (path as NSString).appendingPathComponent("Package.swift") + if let content = try? String(contentsOfFile: packageSwiftPath, encoding: .utf8) { + if let license = identifyLicenseFromPackageSwift(content) { + return license + } + } + + return nil + } + + /// Identify a license from the full text content of a license file. + static func identifyLicenseFromContent(_ content: String) -> DetectedLicense? { + let upper = content.uppercased() + + // Check for SPDX-License-Identifier header first + if let spdxMatch = extractSPDXIdentifier(from: content) { + return DetectedLicense(spdxIdentifier: spdxMatch, name: spdxMatch) + } + + // Apache 2.0 + if upper.contains("APACHE LICENSE") && upper.contains("VERSION 2.0") { + return DetectedLicense(spdxIdentifier: "Apache-2.0", name: "Apache License 2.0") + } + + // MIT + if upper.contains("MIT LICENSE") || upper.contains("PERMISSION IS HEREBY GRANTED, FREE OF CHARGE") { + return DetectedLicense(spdxIdentifier: "MIT", name: "MIT License") + } + + // BSD 3-Clause + if upper.contains("BSD") && upper.contains("3-CLAUSE") { + return DetectedLicense(spdxIdentifier: "BSD-3-Clause", name: "BSD 3-Clause License") + } + if upper.contains("REDISTRIBUTION AND USE IN SOURCE AND BINARY FORMS") && upper.contains("NEITHER THE NAME") { + return DetectedLicense(spdxIdentifier: "BSD-3-Clause", name: "BSD 3-Clause License") + } + + // BSD 2-Clause + if upper.contains("BSD") && upper.contains("2-CLAUSE") { + return DetectedLicense(spdxIdentifier: "BSD-2-Clause", name: "BSD 2-Clause License") + } + if upper.contains("REDISTRIBUTION AND USE IN SOURCE AND BINARY FORMS") && !upper.contains("NEITHER THE NAME") { + return DetectedLicense(spdxIdentifier: "BSD-2-Clause", name: "BSD 2-Clause License") + } + + // ISC + if upper.contains("ISC LICENSE") || (upper.contains("PERMISSION TO USE, COPY, MODIFY") && upper.contains("ISC")) { + return DetectedLicense(spdxIdentifier: "ISC", name: "ISC License") + } + + // MPL 2.0 + if upper.contains("MOZILLA PUBLIC LICENSE") && upper.contains("VERSION 2.0") { + return DetectedLicense(spdxIdentifier: "MPL-2.0", name: "Mozilla Public License 2.0") + } + + // LGPL 3.0 (with or without linking exception) + if upper.contains("GNU LESSER GENERAL PUBLIC LICENSE") && upper.contains("VERSION 3") || upper.contains("LGPL3") { + if upper.contains("LINKING EXCEPTION") || upper.contains("LINKS STATICALLY OR DYNAMICALLY") { + return DetectedLicense(spdxIdentifier: "LGPL-3.0-only WITH LGPL-3.0-linking-exception", name: "GNU Lesser General Public License v3.0 with Linking Exception") + } + return DetectedLicense(spdxIdentifier: "LGPL-3.0-only", name: "GNU Lesser General Public License v3.0") + } + + // AGPL 3.0 (must be checked before GPL since AGPL text contains "GNU GENERAL PUBLIC LICENSE") + if upper.contains("GNU AFFERO GENERAL PUBLIC LICENSE") && upper.contains("VERSION 3") { + return DetectedLicense(spdxIdentifier: "AGPL-3.0-only", name: "GNU Affero General Public License v3.0") + } + + // GPL 3.0 + if upper.contains("GNU GENERAL PUBLIC LICENSE") && upper.contains("VERSION 3") { + return DetectedLicense(spdxIdentifier: "GPL-3.0-only", name: "GNU General Public License v3.0") + } + + // GPL 2.0 + if upper.contains("GNU GENERAL PUBLIC LICENSE") && upper.contains("VERSION 2") { + return DetectedLicense(spdxIdentifier: "GPL-2.0-only", name: "GNU General Public License v2.0") + } + + // Unlicense + if upper.contains("THIS IS FREE AND UNENCUMBERED SOFTWARE RELEASED INTO THE PUBLIC DOMAIN") { + return DetectedLicense(spdxIdentifier: "Unlicense", name: "The Unlicense") + } + + // Zlib + if upper.contains("ZLIB LICENSE") || (upper.contains("ZLIB") && upper.contains("FREELY") && upper.contains("ALTERED SOURCE VERSIONS")) { + return DetectedLicense(spdxIdentifier: "Zlib", name: "zlib License") + } + + // CC0 + if upper.contains("CC0 1.0 UNIVERSAL") || upper.contains("CREATIVE COMMONS ZERO") { + return DetectedLicense(spdxIdentifier: "CC0-1.0", name: "Creative Commons Zero v1.0 Universal") + } + + // EUPL + if upper.contains("EUROPEAN UNION PUBLIC LICENCE") { + return DetectedLicense(spdxIdentifier: "EUPL-1.2", name: "European Union Public License 1.2") + } + + // OSL + if upper.contains("OPEN SOFTWARE LICENSE") && upper.contains("3.0") { + return DetectedLicense(spdxIdentifier: "OSL-3.0", name: "Open Software License 3.0") + } + + // Swift License (Apache 2.0 with Runtime Library Exception) + if upper.contains("APACHE LICENSE") && upper.contains("SWIFT RUNTIME LIBRARY EXCEPTION") { + return DetectedLicense(spdxIdentifier: "Apache-2.0 WITH Swift-exception", name: "Apache License 2.0 with Swift Runtime Library Exception") + } + + // Boost + if upper.contains("BOOST SOFTWARE LICENSE") { + return DetectedLicense(spdxIdentifier: "BSL-1.0", name: "Boost Software License 1.0") + } + + return nil + } + + // MARK: - SPDX Compatibility + + /// Check whether two SPDX license identifiers are compatible. + /// Treats `-only` and `-or-later` variants of the same license as compatible + /// (e.g. `GPL-2.0-only` is compatible with `GPL-2.0-or-later`). + static func areCompatible(_ a: String, _ b: String) -> Bool { + if a == b { return true } + // Strip the -only/-or-later suffix and compare the base license + let baseA = spdxBase(a) + let baseB = spdxBase(b) + return baseA == baseB && baseA != a // only match if a suffix was actually stripped + } + + /// Return the base license identifier by stripping `-only` or `-or-later` suffixes, + /// and also stripping ` WITH ...` exception clauses for the base comparison. + private static func spdxBase(_ identifier: String) -> String { + var id = identifier + // Strip WITH clauses: "GPL-2.0-only WITH Classpath-exception-2.0" -> "GPL-2.0-only" + if let withRange = id.range(of: " WITH ", options: .caseInsensitive) { + id = String(id[id.startIndex.. String? { + let lines = content.components(separatedBy: .newlines) + for line in lines { + if let range = line.range(of: "SPDX-License-Identifier:", options: .caseInsensitive) { + let identifier = line[range.upperBound...].trimmingCharacters(in: .whitespaces) + if !identifier.isEmpty { + return identifier + } + } + } + return nil + } + + /// Identify a license from a Package.swift file's content. + static func identifyLicenseFromPackageSwift(_ content: String) -> DetectedLicense? { + if content.contains(".MIT") { + return DetectedLicense(spdxIdentifier: "MIT", name: "MIT License") + } + if content.contains(".apache") || content.contains("Apache") { + return DetectedLicense(spdxIdentifier: "Apache-2.0", name: "Apache License 2.0") + } + return nil + } + + // MARK: - SPDX LicenseRef Resolution (for SPDX documents) + + /// Build a mapping from LicenseRef-* identifiers to standard SPDX identifiers + /// using the document's `hasExtractedLicensingInfos` section. + static func buildLicenseRefLookup(from doc: [String: Any]) -> [String: String] { + guard let infos = doc["hasExtractedLicensingInfos"] as? [[String: Any]] else { + return [:] + } + + var lookup: [String: String] = [:] + for info in infos { + guard let licenseId = info["licenseId"] as? String else { continue } + let name = info["name"] as? String ?? "" + let urls = info["seeAlsos"] as? [String] ?? [] + + if let spdx = resolveLicenseRef(name: name, urls: urls) { + lookup[licenseId] = spdx + } + } + return lookup + } + + /// Build a mapping from LicenseRef-* identifiers to their human-readable names + /// from the document's `hasExtractedLicensingInfos` section. + static func buildLicenseRefNames(from doc: [String: Any]) -> [String: String] { + guard let infos = doc["hasExtractedLicensingInfos"] as? [[String: Any]] else { + return [:] + } + + var names: [String: String] = [:] + for info in infos { + guard let licenseId = info["licenseId"] as? String, + let name = info["name"] as? String, !name.isEmpty else { continue } + names[licenseId] = name + } + return names + } + + /// Attempt to map a license name and URLs from an extracted licensing info entry + /// to a standard SPDX identifier. + static func resolveLicenseRef(name: String, urls: [String]) -> String? { + // First check if the name itself is already a valid SPDX identifier + if flossLicenses.contains(name) { + return name + } + + // Try URL-based matching (most reliable) + for url in urls { + if let spdx = matchLicenseURL(url) { + return spdx + } + } + + // Try name-based matching + if let spdx = matchLicenseName(name) { + return spdx + } + + return nil + } + + // MARK: - URL-Based License Matching + + /// Well-known license URL patterns to SPDX identifiers. + static let licenseURLPatterns: [(pattern: String, spdx: String)] = [ + ("apache.org/licenses/license-2.0", "Apache-2.0"), + ("opensource.org/licenses/mit", "MIT"), + ("opensource.org/license/mit", "MIT"), + ("spdx.org/licenses/mit", "MIT"), + ("opensource.org/licenses/bsd-license", "BSD-2-Clause"), + ("opensource.org/licenses/bsd-2-clause", "BSD-2-Clause"), + ("opensource.org/licenses/bsd-3-clause", "BSD-3-Clause"), + ("eclipse.org/legal/epl-2.0", "EPL-2.0"), + ("eclipse.org/legal/epl-v20", "EPL-2.0"), + ("eclipse.org/legal/epl-v10", "EPL-1.0"), + ("eclipse.org/org/documents/edl-v10", "BSD-3-Clause"), + ("gnu.org/licenses/lgpl-2.1", "LGPL-2.1-or-later"), + ("gnu.org/licenses/old-licenses/lgpl-2.1", "LGPL-2.1-or-later"), + ("gnu.org/licenses/lgpl-3", "LGPL-3.0-or-later"), + ("gnu.org/licenses/gpl-2", "GPL-2.0-or-later"), + ("gnu.org/licenses/old-licenses/gpl-2", "GPL-2.0-or-later"), + ("gnu.org/licenses/gpl-3", "GPL-3.0-or-later"), + ("creativecommons.org/publicdomain/zero/1.0", "CC0-1.0"), + ("creativecommons.org/licenses/by/4.0", "CC-BY-4.0"), + ("creativecommons.org/licenses/by/3.0", "CC-BY-3.0"), + ("mozilla.org/mpl/2.0", "MPL-2.0"), + ("golang.org/license", "BSD-3-Clause"), + ] + + /// Match a URL against well-known license URL patterns. + static func matchLicenseURL(_ url: String) -> String? { + let lower = url.lowercased() + for mapping in licenseURLPatterns { + if lower.contains(mapping.pattern) { + return mapping.spdx + } + } + return nil + } + + // MARK: - Name-Based License Matching + + /// Well-known license names to SPDX identifiers (case-insensitive exact match). + static let licenseNameMap: [String: String] = [ + // Apache + "the apache software license, version 2.0": "Apache-2.0", + "the apache license, version 2.0": "Apache-2.0", + "apache license, version 2.0": "Apache-2.0", + "apache license 2.0": "Apache-2.0", + "apache-2.0": "Apache-2.0", + "apache 2.0": "Apache-2.0", + "apache 2": "Apache-2.0", + // MIT + "the mit license": "MIT", + "mit license": "MIT", + "mit": "MIT", + "mit-0": "MIT-0", + // BSD + "bsd 3-clause license": "BSD-3-Clause", + "the 3-clause bsd license": "BSD-3-Clause", + "new bsd license": "BSD-3-Clause", + "bsd-3-clause": "BSD-3-Clause", + "revised bsd license": "BSD-3-Clause", + "bsd 2-clause license": "BSD-2-Clause", + "simplified bsd license": "BSD-2-Clause", + "the bsd license": "BSD-2-Clause", + "bsd-2-clause": "BSD-2-Clause", + "bsd license": "BSD-2-Clause", + // Eclipse + "eclipse public license 2.0": "EPL-2.0", + "eclipse public license - v 2.0": "EPL-2.0", + "epl 2.0": "EPL-2.0", + "epl-2.0": "EPL-2.0", + "eclipse public license 1.0": "EPL-1.0", + "eclipse public license - v 1.0": "EPL-1.0", + "epl 1.0": "EPL-1.0", + "epl-1.0": "EPL-1.0", + "eclipse distribution license - v 1.0": "BSD-3-Clause", + "edl 1.0": "BSD-3-Clause", + // GPL/LGPL + "gnu general public license, version 2": "GPL-2.0-only", + "gpl-2.0": "GPL-2.0-only", + "gnu general public license, version 3": "GPL-3.0-only", + "gpl-3.0": "GPL-3.0-only", + "gnu lesser general public license, version 2.1": "LGPL-2.1-only", + "lgpl-2.1": "LGPL-2.1-only", + "lgpl-2.1-or-later": "LGPL-2.1-or-later", + "gnu lesser general public license, version 3": "LGPL-3.0-only", + "lgpl-3.0": "LGPL-3.0-only", + "lgpl-3.0-or-later": "LGPL-3.0-or-later", + "gpl2 w/ cpe": "GPL-2.0-only WITH Classpath-exception-2.0", + // Mozilla + "mozilla public license, version 2.0": "MPL-2.0", + "mozilla public license 2.0": "MPL-2.0", + "mpl 2.0": "MPL-2.0", + "mpl-2.0": "MPL-2.0", + // Other + "cc0 1.0 universal": "CC0-1.0", + "cc0": "CC0-1.0", + "the unlicense": "Unlicense", + "unlicense": "Unlicense", + "isc license": "ISC", + "isc": "ISC", + "zlib license": "Zlib", + "zlib": "Zlib", + "boost software license 1.0": "BSL-1.0", + "bsl-1.0": "BSL-1.0", + "bouncy castle licence": "MIT", + "go license": "BSD-3-Clause", + "cddl 1.0": "CDDL-1.0", + "cddl 1.1": "CDDL-1.1", + "artistic license 2.0": "Artistic-2.0", + "postgresql license": "PostgreSQL", + ] + + /// Match a license name against well-known names (case-insensitive exact match). + static func matchLicenseName(_ name: String) -> String? { + licenseNameMap[name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()] + } + + // MARK: - SPDX Expression Parsing + + /// Parse an SPDX license expression into its individual license identifiers. + /// Handles compound expressions like "(Apache-2.0 AND MIT)" or "Apache-2.0 OR MIT" + /// and "WITH" exception suffixes like "GPL-2.0-only WITH Classpath-exception-2.0". + static func parseSPDXExpression(_ expression: String) -> [String] { + var expr = expression.trimmingCharacters(in: .whitespaces) + while expr.hasPrefix("(") && expr.hasSuffix(")") { + expr = String(expr.dropFirst().dropLast()).trimmingCharacters(in: .whitespaces) + } + + var components: [String] = [] + var remaining = expr + + while !remaining.isEmpty { + var bestRange: Range? = nil + for op in [" AND ", " OR "] { + if let range = remaining.range(of: op) { + if bestRange == nil || range.lowerBound < bestRange!.lowerBound { + bestRange = range + } + } + } + + if let range = bestRange { + let part = String(remaining[remaining.startIndex.. = [ + // Permissive licenses + "MIT", + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unlicense", + "CC0-1.0", + "BSL-1.0", + "Zlib", + "0BSD", + "MIT-0", + "WTFPL", + "X11", + "curl", + "PostgreSQL", + + // Copyleft licenses + "GPL-2.0-only", + "GPL-2.0-or-later", + "GPL-3.0-only", + "GPL-3.0-or-later", + "LGPL-2.0-only", + "LGPL-2.0-or-later", + "LGPL-2.1-only", + "LGPL-2.1-or-later", + "LGPL-3.0-only", + "LGPL-3.0-or-later", + "AGPL-3.0-only", + "AGPL-3.0-or-later", + "MPL-2.0", + "EPL-1.0", + "EPL-2.0", + "EUPL-1.1", + "EUPL-1.2", + "OSL-3.0", + "CDDL-1.0", + "CDDL-1.1", + "CPL-1.0", + "IPL-1.0", + "Artistic-2.0", + + // Common compound/exception expressions + "GPL-2.0-only WITH Classpath-exception-2.0", + "GPL-2.0-or-later WITH Classpath-exception-2.0", + "Apache-2.0 WITH LLVM-exception", + "Apache-2.0 WITH Swift-exception", + "LGPL-3.0-only WITH LGPL-3.0-linking-exception", + + // Creative Commons (content/data licenses) + "CC-BY-3.0", + "CC-BY-4.0", + "CC-BY-SA-3.0", + "CC-BY-SA-4.0", + ] +} + private let licenseEUPLContents = """ EUROPEAN UNION PUBLIC LICENCE v. 1.2 EUPL © the European Union 2007, 2016