diff --git a/.github/scripts/report-swift-extraction.swift b/.github/scripts/report-swift-extraction.swift new file mode 100644 index 0000000..8b794a6 --- /dev/null +++ b/.github/scripts/report-swift-extraction.swift @@ -0,0 +1,168 @@ +import Foundation + +enum InvocationSummaryError: Error { + case invalidLog(String) + case ioFailure(String, NSError) +} + +struct SwiftInvocationSummary: Encodable { + let module: String + let target: String + let sdk: String + let sourceArgumentCount: Int + let moduleEmission: Bool + let objcHeaderEmission: Bool + let executableEmission: Bool + let accessibilityAmbiguityCount: Int + let otherCompilerErrorCount: Int +} + +struct SwiftLogSummary: Encodable { + let examinedInvocationLogCount: Int + let invocations: [SwiftInvocationSummary] +} + +func capturedValues(_ pattern: String, _ text: String) throws -> [String] { + let regex: NSRegularExpression = try NSRegularExpression(pattern: pattern) + let range: NSRange = NSRange(text.startIndex.. = Range(match.range(at: 1), in: text) else { + throw InvocationSummaryError.invalidLog("Extractor metadata capture lacks its required field") + } + return String(text[valueRange]) + } +} + +func singleValue(_ pattern: String, _ text: String, _ field: String) throws -> String { + let values: [String] = try capturedValues(pattern, text) + guard values.count == 1 else { + throw InvocationSummaryError.invalidLog("Expected one extractor " + field + " value") + } + return values[0] +} + +/// Only closed module names, validated public toolchain identifiers and counts leave the log boundary. +func summarizeInvocation(_ text: String) throws -> SwiftInvocationSummary { + let lines: [String] = text.components(separatedBy: "\n") + guard lines.count <= 100_000, lines.allSatisfy({ $0.utf8.count <= 256 * 1024 }) else { + throw InvocationSummaryError.invalidLog("Extractor log line bounds exceeded") + } + let commands: [String] = lines.filter { $0.contains("calling extractor with arguments \"") } + guard commands.count == 1 else { + throw InvocationSummaryError.invalidLog("Expected one command record per extractor invocation log") + } + let command: String = commands[0] + let module: String = try singleValue(#"-module-name\s+([^\s\"]+)"#, command, "module") + let modules: Set = ["SystemProfilerExplorer", "SystemProfilerExplorerTests", "SwiftExtractionSummary"] + guard modules.contains(module) else { + throw InvocationSummaryError.invalidLog("Extractor module is outside the closed diagnostic contract") + } + let target: String = try singleValue(#"-target\s+((?:arm64|x86_64)-apple-macosx?[0-9]{1,2}(?:\.[0-9]{1,2}){0,2})(?=\s|\")"#, command, "target") + let sdk: String = try singleValue(#"-sdk\s+[^\s\"]*/(MacOSX[0-9]{1,2}(?:\.[0-9]{1,2}){0,2}\.sdk)(?=\s|\")"#, command, "SDK") + let sources: [String] = try capturedValues(#"(?:\s|^)([^\s\"]+\.swift)(?=\s|\")"#, command) + let compilerErrors: [String] = lines.filter { $0.contains("ERRO [extractor/compiler]") } + let ambiguities: Int = compilerErrors.filter { $0.contains("reference to 'NSAccessibilityElement' is ambiguous") }.count + return SwiftInvocationSummary( + module: module, target: target, sdk: sdk, sourceArgumentCount: sources.count, + moduleEmission: !(try capturedValues(#"(?:\s|")(-emit-module)(?=\s|")"#, command)).isEmpty, + objcHeaderEmission: !(try capturedValues(#"(?:\s|")(-emit-objc-header)(?=\s|")"#, command)).isEmpty, + executableEmission: !(try capturedValues(#"(?:\s|")(-emit-executable)(?=\s|")"#, command)).isEmpty, + accessibilityAmbiguityCount: ambiguities, + otherCompilerErrorCount: compilerErrors.count - ambiguities + ) +} + +func checkedDirectory(_ url: URL) throws -> URL { + let values: URLResourceValues + do { + values = try url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + } catch let error as NSError { + throw InvocationSummaryError.ioFailure("inspect extractor log directory", error) + } + guard values.isDirectory == true, values.isSymbolicLink == false else { + throw InvocationSummaryError.invalidLog("Extractor log directory is missing or is a symbolic link") + } + return url +} + +func readInvocationLogs(_ temporaryDirectory: URL) throws -> [String] { + var extractorDirectory: URL = try checkedDirectory(temporaryDirectory.resolvingSymlinksInPath()) + for component in ["codeql_databases", "swift", "log", "swift", "extractor"] { + extractorDirectory = try checkedDirectory(extractorDirectory.appendingPathComponent(component)) + } + let files: [URL] + do { + files = try FileManager.default.contentsOfDirectory(at: extractorDirectory, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey]) + } catch let error as NSError { + throw InvocationSummaryError.ioFailure("list Swift extractor invocation logs", error) + } + let logs: [URL] = files.filter { $0.pathExtension == "log" }.sorted { $0.lastPathComponent < $1.lastPathComponent } + guard !logs.isEmpty, logs.count <= 500 else { + throw InvocationSummaryError.invalidLog("Swift invocation log count must be between 1 and 500") + } + var totalBytes: Int = 0 + return try logs.map { url in + let values: URLResourceValues + do { + values = try url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey]) + } catch let error as NSError { + throw InvocationSummaryError.ioFailure("inspect Swift extractor invocation log", error) + } + guard values.isRegularFile == true, values.isSymbolicLink == false, + let size: Int = values.fileSize, size >= 0, size <= 20 * 1024 * 1024 else { + throw InvocationSummaryError.invalidLog("Invocation log must be a bounded regular file without a symbolic link") + } + let data: Data + do { + data = try Data(contentsOf: url, options: .mappedIfSafe) + } catch let error as NSError { + throw InvocationSummaryError.ioFailure("read Swift extractor invocation log", error) + } + guard data.count <= 20 * 1024 * 1024 else { + throw InvocationSummaryError.invalidLog("Swift invocation log changed beyond the byte limit during reading") + } + totalBytes += data.count + guard totalBytes <= 80 * 1024 * 1024 else { + throw InvocationSummaryError.invalidLog("Swift invocation logs exceed the total byte limit") + } + guard let text: String = String(data: data, encoding: .utf8) else { + throw InvocationSummaryError.invalidLog("Swift invocation log must contain valid UTF-8") + } + return text + } +} + +func writeSummary(_ arguments: [String]) throws { + guard arguments.count == 2 else { + throw InvocationSummaryError.invalidLog("Expected the runner temporary directory and summary output path") + } + let temporaryDirectory: URL = URL(fileURLWithPath: arguments[0]) + let output: URL = URL(fileURLWithPath: arguments[1]) + guard output.standardizedFileURL.path == temporaryDirectory.appendingPathComponent("swift-invocation-summary.json").standardizedFileURL.path else { + throw InvocationSummaryError.invalidLog("Summary output must use the fixed task-owned temporary file") + } + let logs: [String] = try readInvocationLogs(temporaryDirectory) + let summary: SwiftLogSummary = SwiftLogSummary(examinedInvocationLogCount: logs.count, invocations: try logs.map(summarizeInvocation)) + let encoder: JSONEncoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data: Data = try encoder.encode(summary) + guard data.count <= 256 * 1024 else { + throw InvocationSummaryError.invalidLog("Bounded invocation metadata exceeds the output byte limit") + } + do { + try data.write(to: output, options: .withoutOverwriting) + } catch let error as NSError { + throw InvocationSummaryError.ioFailure("write bounded Swift invocation summary", error) + } + FileHandle.standardOutput.write(data + Data("\n".utf8)) +} + +do { + try writeSummary(Array(CommandLine.arguments.dropFirst())) +} catch InvocationSummaryError.invalidLog(let reason) { + FileHandle.standardError.write(Data("Swift invocation summary rejected: \(reason).\n".utf8)) + exit(1) +} catch InvocationSummaryError.ioFailure(let operation, let error) { + FileHandle.standardError.write(Data("Swift invocation summary I/O failed: operation=\(operation) domain=\(error.domain) code=\(error.code).\n".utf8)) + exit(1) +} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 922611a..45e3547 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -37,7 +37,7 @@ jobs: os: ubuntu-latest build-mode: none env: - DEVELOPER_DIR: ${{ matrix.language == 'swift' && '/Applications/Xcode_26.4.1.app/Contents/Developer' || '' }} + DEVELOPER_DIR: ${{ matrix.language == 'swift' && '/Applications/Xcode_26.6.app/Contents/Developer' || '' }} steps: - name: Check out source uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -48,9 +48,29 @@ jobs: with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} + - name: Report Swift extraction toolchain + if: matrix.language == 'swift' + run: | + xcodebuild -version + xcrun swiftc --version + xcrun --sdk macosx --show-sdk-version + xcrun --sdk macosx --show-sdk-build-version - name: Build Swift source for extraction if: matrix.language == 'swift' - run: xcodebuild -project SystemProfilerExplorer.xcodeproj -scheme SystemProfilerExplorer -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build test + run: | + xcrun swiftc -swift-version 6 -module-name SwiftExtractionSummary \ + -target x86_64-apple-macos13.0 -sdk "$(xcrun --sdk macosx --show-sdk-path)" \ + .github/scripts/report-swift-extraction.swift -o "$RUNNER_TEMP/report-swift-extraction" + xcodebuild -project SystemProfilerExplorer.xcodeproj \ + -scheme SystemProfilerExplorer \ + -destination 'platform=macOS,arch=x86_64' \ + -derivedDataPath "$RUNNER_TEMP/codeql-build" \ + ARCHS=x86_64 ONLY_ACTIVE_ARCH=YES \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \ + COMPILATION_CACHE_ENABLE_CACHING=NO SWIFT_ENABLE_COMPILE_CACHE=NO \ + SWIFT_USE_INTEGRATED_DRIVER=NO SWIFT_ENABLE_EXPLICIT_MODULES=NO \ + SWIFT_COMPILATION_MODE=wholemodule \ + build test - name: Analyze source without upload permissions uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4 with: @@ -58,7 +78,46 @@ jobs: output: results upload: never upload-database: false + - name: Reject Swift extraction errors + if: matrix.language == 'swift' + env: + SARIF_FILE: results/swift.sarif + run: | + if [[ ! -s "$SARIF_FILE" ]]; then + printf 'Swift SARIF is missing or empty.\n' >&2 + exit 1 + fi + error_count="$(jq -er ' + if type != "object" or (.runs | type) != "array" or (.runs | length) == 0 then + error("Invalid Swift SARIF runs") + elif any(.runs[]; type != "object" or (.invocations | type) != "array" or (.invocations | length) == 0) then + error("Invalid Swift SARIF invocations") + elif any(.runs[].invocations[]; type != "object" or (.toolExecutionNotifications | type) != "array") then + error("Invalid Swift SARIF notifications") + elif any(.runs[].invocations[].toolExecutionNotifications[]; type != "object" or (.descriptor | type) != "object" or (.descriptor.id | type) != "string") then + error("Invalid Swift SARIF notification descriptor") + else + [.runs[].invocations[].toolExecutionNotifications[] | select(.descriptor.id == "swift/diagnostics/extraction-errors")] | length + end + ' "$SARIF_FILE")" + if [[ "$error_count" != "0" ]]; then + printf 'Swift extraction reported %s compiler diagnostics; inspect the analysis diagnostics before publishing SARIF.\n' "$error_count" >&2 + exit 1 + fi + printf 'Swift extraction compiler diagnostics: 0\n' + - name: Summarize bounded Swift invocation diagnostics + if: matrix.language == 'swift' && (success() || failure()) + run: '"$RUNNER_TEMP/report-swift-extraction" "$RUNNER_TEMP" "$RUNNER_TEMP/swift-invocation-summary.json"' + - name: Retain bounded Swift context after failure + if: matrix.language == 'swift' && failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: swift-invocation-context + path: ${{ runner.temp }}/swift-invocation-summary.json + if-no-files-found: error + retention-days: 1 - name: Retain analysis results for the upload job + if: success() || failure() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: codeql-results-${{ matrix.language }}