From 659069aa3e7d9f4b71c6ca28a6e576dde39efb97 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 20 Apr 2026 06:50:04 -0700 Subject: [PATCH 01/15] Plugins: restore InstallSafeDITool download command + build-plugin warn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings back the command plugin we had in 1.5.x for downloading the prebuilt SafeDITool release binary. Adapted for 2.x's macos- asset naming and integrated with the existing build plugin's fallback logic. **Why** SPM command plugins have no access to DerivedData (verified via full env dump — Xcode exposes only the user's shell environment to plugin processes), so we can't symlink to Xcode's downloaded artifact bundle. A fresh download is the simplest reliable path. Two scenarios benefit: 1. **Xcode + sourceBuild trait**: `context.tool(named:).url` returns an unresolved `${BUILD_DIR}/${CONFIGURATION}/SafeDITool` template that can't be executed during plugin setup. The plugin currently falls back to the regex-based `PluginScanner`. A downloaded prebuilt at `.safedi//safeditool` lets the plugin run the real parser's scan subcommand for authoritative output discovery. 2. **swift build + sourceBuild trait**: SafeDITool is built in debug config, ~15× slower than the release binary. Downloading the prebuilt release restores prod-speed codegen. **What changes** - `Plugins/InstallSafeDITool/` — new command plugin (`safedi-install-tool` verb). Downloads the arch-appropriate binary from `https://github.com/dfed/SafeDI/releases/download//SafeDITool-macos-`, installs it at `.safedi//safeditool`, marks it executable, and writes a `.safedi/.gitignore` excluding the binary (which is per-machine and per-version). - `Plugins/Shared.swift` — `safediFolder` / `expectedToolLocation` / `downloadedToolLocation` / `safeDIVersion` helpers on both `PluginContext` and `XcodePluginContext`. Mirrors the 1.5.4 shape. Xcode's `safeDIVersion` is hardcoded (Xcode plugins can't read the package manifest) — acceptable since the binary format is forward-compatible within a minor release line. - `Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift` — prefers `downloadedToolLocation` over the SPM-provided tool. Emits a `Diagnostics.warning` that points to the exact `swift package safedi-install-tool` invocation when the prebuilt isn't installed. The Xcode variant additionally runs the real `scan` subcommand when the prebuilt is available (instead of falling back to PluginScanner unconditionally). - `Package.swift` — registers `InstallSafeDITool` as a command plugin with `.writeToPackageDirectory` + `.allowNetworkConnections` permissions. - `Documentation/Manual.md` — "Installing the prebuilt SafeDITool binary" subsection under the Xcode project integration section. Explains the warning, the install command, the one-time-per-project step, and why Xcode requires it. Local-path and root-package references (as in our example projects) are rejected with a clear error — the install flow only makes sense with versioned releases. All 885 tests pass; lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- Documentation/Manual.md | 17 ++ Package.swift | 26 +++ .../InstallSafeDITool/InstallSafeDITool.swift | 191 ++++++++++++++++++ Plugins/InstallSafeDITool/Shared.swift | 1 + .../SafeDIGenerateDependencyTree.swift | 102 +++++++++- Plugins/Shared.swift | 96 +++++++++ 6 files changed, 425 insertions(+), 8 deletions(-) create mode 100644 Plugins/InstallSafeDITool/InstallSafeDITool.swift create mode 120000 Plugins/InstallSafeDITool/Shared.swift diff --git a/Documentation/Manual.md b/Documentation/Manual.md index ccd7b7ae..6ed8fa33 100644 --- a/Documentation/Manual.md +++ b/Documentation/Manual.md @@ -42,6 +42,23 @@ import SafeDI The `additionalDirectoriesToInclude` parameter specifies folders outside of your module that SafeDI will scan for Swift source files. Paths must be relative to the project directory. Use this parameter to specify the paths to dependent modules' source directories, since Xcode project plugins cannot discover these automatically. You can see [an example of this configuration](../Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/SafeDIConfiguration.swift) in the [ExampleMultiProjectIntegration](../Examples/ExampleMultiProjectIntegration) project. +##### Installing the prebuilt SafeDITool binary + +If you see a build warning that starts with **"SafeDI's build-tool plugin is falling back to a regex-based output scanner..."** — or **"SafeDI is running a locally-built SafeDITool in the debug configuration..."** — installing the prebuilt tool fixes it: + +``` +swift package --package-path "/path/to/YourXcodeProject" \ + --allow-network-connections all \ + --allow-writing-to-package-directory \ + safedi-install-tool +``` + +This downloads the prebuilt SafeDITool release binary for your SafeDI version into `.safedi//safeditool` next to your `.xcodeproj`. The build plugin picks it up automatically on subsequent builds. + +**Why this step is required in Xcode:** Swift package build-tool plugins are told the SafeDITool location via an Xcode build-variable template (`${BUILD_DIR}/${CONFIGURATION}/SafeDITool`) whose values aren't exposed to the plugin's process environment at plugin-setup time. SafeDI's plugin therefore can't execute the real parser during setup and falls back to a regex-based output scanner. A prebuilt binary at a known path sidesteps the template entirely. The install command is a one-time step per project (re-run it after a SafeDI version bump if the warning reappears). + +**What to commit:** the command writes a `.safedi/.gitignore` that excludes the per-version binary from source control — the binary is host-specific (arm64 vs x86_64) and per-version, so it shouldn't be committed. Developers new to the project re-run the install command once after cloning. + #### Swift package If your first-party code is entirely contained in a Swift Package with one or more modules, you can add the following lines to your root target’s definition: diff --git a/Package.swift b/Package.swift index 97d1cbdd..40939f54 100644 --- a/Package.swift +++ b/Package.swift @@ -35,6 +35,10 @@ let package = Package( name: "MigrateSafeDIFromVersionOne", targets: ["MigrateSafeDIFromVersionOne"], ), + .plugin( + name: "InstallSafeDITool", + targets: ["InstallSafeDITool"], + ), ], traits: [ .default(enabledTraits: ["prebuilt"]), @@ -101,6 +105,28 @@ let package = Package( dependencies: [], ), + // Downloads the prebuilt SafeDITool release binary into + // `/.safedi//safeditool`. The build plugin prefers + // that path over the SPM-provided tool — it avoids the + // `${BUILD_DIR}`-in-tool-path problem that forces Xcode sourceBuild + // users onto the regex-based `PluginScanner` fallback, and it avoids + // the ~15× slower debug build that SPM produces when sourceBuild is + // active. + .plugin( + name: "InstallSafeDITool", + capability: .command( + intent: .custom( + verb: "safedi-install-tool", + description: "Downloads the SafeDITool prebuilt release binary for the current SafeDI version.", + ), + permissions: [ + .writeToPackageDirectory(reason: "Downloads the SafeDITool binary into .safedi//safeditool."), + .allowNetworkConnections(scope: .all(ports: []), reason: "Downloads SafeDITool from the SafeDI GitHub release."), + ], + ), + dependencies: [], + ), + .plugin( name: "SafeDIGenerator", capability: .buildTool(), diff --git a/Plugins/InstallSafeDITool/InstallSafeDITool.swift b/Plugins/InstallSafeDITool/InstallSafeDITool.swift new file mode 100644 index 00000000..8276d1bb --- /dev/null +++ b/Plugins/InstallSafeDITool/InstallSafeDITool.swift @@ -0,0 +1,191 @@ +// Distributed under the MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif +import PackagePlugin + +/// Downloads the prebuilt SafeDITool release binary for the current SafeDI +/// version into `/.safedi//safeditool` (or the Xcode +/// project's equivalent directory). The build plugin prefers this path over +/// the SPM-provided tool because: +/// +/// 1. **Xcode + sourceBuild trait**: `context.tool(named:)` returns an +/// unresolved `${BUILD_DIR}/${CONFIGURATION}/SafeDITool` template path +/// at plugin-setup time that can't be executed, forcing a fall back to +/// the lossy regex-based `PluginScanner`. A downloaded prebuilt gives +/// the plugin-setup scan the real parser's output. +/// 2. **swift build + sourceBuild trait**: SafeDITool is built in DEBUG +/// config, which is ~15× slower than the release binary. Downloading +/// the prebuilt release restores prod-speed codegen. +@main +struct InstallSafeDITool: CommandPlugin { + func performCommand( + context: PackagePlugin.PluginContext, + arguments _: [String], + ) async throws { + guard let safeDIOrigin = context.package.dependencies.first(where: { $0.package.displayName == "SafeDI" })?.package.origin else { + Diagnostics.error("No package origin found for SafeDI package.") + exit(1) + } + guard let version = context.safeDIVersion, + let expectedToolFolder = context.expectedToolFolder, + let expectedToolLocation = context.expectedToolLocation + else { + Diagnostics.error("Could not extract version for SafeDI. The install plugin only works when SafeDI is consumed via a versioned release (not a local or root package reference).") + exit(1) + } + + switch safeDIOrigin { + case let .repository(url, _, _): + guard let originURL = URL(string: url)?.deletingPathExtension() else { + Diagnostics.error("No package URL found for SafeDI package.") + exit(1) + } + try await downloadTool( + originURL: originURL, + version: version, + expectedToolFolder: expectedToolFolder, + expectedToolLocation: expectedToolLocation, + safediFolder: context.safediFolder, + ) + + case .registry, .root, .local: + fallthrough + + @unknown default: + Diagnostics.error("Cannot download SafeDITool from \(safeDIOrigin) — downloading only works when using a versioned release of SafeDI.") + exit(1) + } + } +} + +#if canImport(XcodeProjectPlugin) + import XcodeProjectPlugin + + extension InstallSafeDITool: XcodeCommandPlugin { + func performCommand( + context: XcodeProjectPlugin.XcodePluginContext, + arguments _: [String], + ) throws { + let version = context.safeDIVersion + let safediFolder = context.safediFolder + let expectedToolFolder = context.expectedToolFolder + let expectedToolLocation = context.expectedToolLocation + let safeDIOrigin = context.safeDIOrigin + + // `XcodeCommandPlugin.performCommand` is synchronous. Bridge to + // the async `downloadTool` helper via a dispatch group so the + // command doesn't return until the download finishes. + let dispatchGroup = DispatchGroup() + dispatchGroup.enter() + var capturedError: Error? + Task.detached { + defer { dispatchGroup.leave() } + do { + try await downloadTool( + originURL: safeDIOrigin, + version: version, + expectedToolFolder: expectedToolFolder, + expectedToolLocation: expectedToolLocation, + safediFolder: safediFolder, + ) + } catch { + capturedError = error + } + } + dispatchGroup.wait() + if let capturedError { + Diagnostics.error("\(capturedError)") + exit(1) + } + } + } +#endif + +/// Downloads the correct architecture's prebuilt binary from GitHub +/// Releases, marks it executable, and moves it into the expected +/// per-version location. Also writes a `.gitignore` inside `.safedi/` +/// (on first run) that excludes the per-version binaries from source +/// control — the binary is per-machine and shouldn't be committed. +private func downloadTool( + originURL: URL, + version: String, + expectedToolFolder: URL, + expectedToolLocation: URL, + safediFolder: URL, +) async throws { + #if arch(arm64) + let toolName = "SafeDITool-macos-arm64" + #elseif arch(x86_64) + let toolName = "SafeDITool-macos-x86_64" + #else + throw UnsupportedArchitectureError() + #endif + + let githubDownloadURL = originURL.appending( + components: "releases", + "download", + version, + toolName, + ) + let (downloadedURL, _) = try await URLSession.shared.download( + for: URLRequest(url: githubDownloadURL), + ) + let downloadedFileAttributes = try FileManager.default.attributesOfItem(atPath: downloadedURL.path(percentEncoded: false)) + guard let currentPermissions = downloadedFileAttributes[.posixPermissions] as? NSNumber, + chmod(downloadedURL.path(percentEncoded: false), mode_t(currentPermissions.uint32Value) | S_IXUSR | S_IXGRP | S_IXOTH) == 0 + else { + throw CouldNotMakeExecutableError(path: downloadedURL.path(percentEncoded: false)) + } + try FileManager.default.createDirectory(at: expectedToolFolder, withIntermediateDirectories: true) + if FileManager.default.fileExists(atPath: expectedToolLocation.path(percentEncoded: false)) { + try FileManager.default.removeItem(at: expectedToolLocation) + } + try FileManager.default.moveItem(at: downloadedURL, to: expectedToolLocation) + + let gitIgnoreLocation = safediFolder.appending(component: ".gitignore") + if !FileManager.default.fileExists(atPath: gitIgnoreLocation.path(percentEncoded: false)) { + // Each version gets its own subfolder (`/safeditool`) so + // the glob `*/safeditool` catches every installed binary. + try """ + */\(expectedToolLocation.lastPathComponent) + """.write( + to: gitIgnoreLocation, + atomically: true, + encoding: .utf8, + ) + } +} + +private struct UnsupportedArchitectureError: Error, CustomStringConvertible { + var description: String { + "Unsupported host architecture for SafeDITool download." + } +} + +private struct CouldNotMakeExecutableError: Error, CustomStringConvertible { + let path: String + var description: String { + "Could not make downloaded file executable: \(path)" + } +} diff --git a/Plugins/InstallSafeDITool/Shared.swift b/Plugins/InstallSafeDITool/Shared.swift new file mode 120000 index 00000000..f125446d --- /dev/null +++ b/Plugins/InstallSafeDITool/Shared.swift @@ -0,0 +1 @@ +../Shared.swift \ No newline at end of file diff --git a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift index 7c02e538..6b759a76 100644 --- a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift @@ -31,7 +31,26 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { return [] } - let tool = try context.tool(named: "SafeDITool").url + // Prefer the user-downloaded prebuilt tool at `.safedi//safeditool` + // when available. Two reasons: (1) it sidesteps the `${BUILD_DIR}`-in-tool- + // path plugin-setup problem for Xcode-driven `sourceBuild` builds, and + // (2) the release build is ~15× faster than the debug build that SPM + // produces for source-built tools. + let tool: URL + if let downloaded = context.downloadedToolLocation { + tool = downloaded + } else { + tool = try context.tool(named: "SafeDITool").url + if let version = context.safeDIVersion { + Diagnostics.warning(""" + SafeDI is running a locally-built SafeDITool in the debug configuration, \ + which is ~15× slower than the prebuilt release binary. To install the \ + release binary for version \(version), run: + + \tswift package --package-path "\(context.package.directoryURL.path(percentEncoded: false))" --allow-network-connections all --allow-writing-to-package-directory safedi-install-tool + """) + } + } let outputDirectory = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutput") let targetSwiftFiles = sourceTarget.sourceFiles(withSuffix: ".swift").map(\.url) let dependenciesSourceFiles = sourceTarget @@ -190,7 +209,31 @@ extension Target { context: XcodeProjectPlugin.XcodePluginContext, target: XcodeProjectPlugin.XcodeTarget, ) throws -> [PackagePlugin.Command] { - let tool = try context.tool(named: "SafeDITool").url + // Prefer the user-downloaded prebuilt tool at + // `.safedi//safeditool` when available. The SPM-provided + // tool path arrives as `${BUILD_DIR}/${CONFIGURATION}/SafeDITool` + // in Xcode, which can't be executed at plugin-setup time and + // forces the regex-based PluginScanner fallback. + let tool: URL + let runsRealScan: Bool + if let downloaded = context.downloadedToolLocation { + tool = downloaded + runsRealScan = true + } else { + tool = try context.tool(named: "SafeDITool").url + runsRealScan = false + Diagnostics.warning(""" + SafeDI's build-tool plugin is falling back to a regex-based output \ + scanner because the SPM-provided SafeDITool path contains unresolved \ + Xcode build variables at plugin-setup time. To install the prebuilt \ + SafeDITool binary for version \(context.safeDIVersion), run: + + \tswift package --package-path "\(context.xcodeProject.directoryURL.path(percentEncoded: false))" --allow-network-connections all --allow-writing-to-package-directory safedi-install-tool + + You may need to create a Package.swift at that path first, or run \ + the command from a directory that contains one. + """) + } let inputSwiftFiles = target .inputFiles .filter { $0.url.pathExtension == "swift" } @@ -208,12 +251,55 @@ extension Target { to: inputSourcesFile, ) - // In Xcode, context.tool(named:) returns paths with unresolved build - // variables that are only resolved at build-command execution time. - // We cannot shell out via Process during createBuildCommands. Instead, - // use a lightweight in-process scan to discover output files, then - // return a .buildCommand that does the full scan+generate at build time - // via the --output-directory flag. + let manifestFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIManifest.json") + + // With a downloaded prebuilt tool we can invoke the real scan + // subcommand, which produces an authoritative manifest. Without + // one, fall through to the lightweight PluginScanner. + if runsRealScan { + do { + try runSafeDITool( + at: tool, + arguments: [ + "scan", + "--input-sources-file", inputSourcesFile.path(percentEncoded: false), + "--project-root", projectRoot.path(percentEncoded: false), + "--output-directory", outputDirectory.path(percentEncoded: false), + "--manifest-file", manifestFile.path(percentEncoded: false), + "--mock-scoped-files", + ] + inputSwiftFiles.map { $0.path(percentEncoded: false) }, + ) + let manifest = try JSONDecoder().decode( + ScanManifest.self, + from: Data(contentsOf: manifestFile), + ) + let outputFiles = (manifest.dependencyTreeGeneration + manifest.mockGeneration) + .map { URL(fileURLWithPath: $0.outputFilePath) } + + (manifest.mockConfigurationOutputFilePath.map { [URL(fileURLWithPath: $0)] } ?? []) + let additionalInputFiles = manifest.additionalInputFiles.map { URL(fileURLWithPath: $0) } + guard !outputFiles.isEmpty else { + return [] + } + return [ + .buildCommand( + displayName: "SafeDIGenerateDependencyTree", + executable: tool, + arguments: [ + inputSourcesFile.path(percentEncoded: false), + "--swift-manifest", + manifestFile.path(percentEncoded: false), + ], + environment: [:], + inputFiles: inputSwiftFiles + additionalInputFiles, + outputFiles: outputFiles, + ), + ] + } catch { + Diagnostics.warning("SafeDITool scan failed (\(error)). Falling back to in-process scan.") + // fall through to PluginScanner below + } + } + let scanResult = PluginScanner.scan( swiftFiles: inputSwiftFiles, mockScopedSwiftFiles: inputSwiftFiles, diff --git a/Plugins/Shared.swift b/Plugins/Shared.swift index 340d2fb6..e58cbb8c 100644 --- a/Plugins/Shared.swift +++ b/Plugins/Shared.swift @@ -19,6 +19,102 @@ // SOFTWARE. import Foundation +import PackagePlugin + +// MARK: - Prebuilt Tool Location + +// Plugin context helpers for locating a prebuilt SafeDITool binary at a +// fixed per-project path (`.safedi//safeditool`). Consumers of +// SafeDI in Xcode hit a `context.tool(named:)` template-path problem when +// the `sourceBuild` trait is active — the plugin-setup phase can't execute +// the tool. A user-downloaded prebuilt at the known location sidesteps that +// and also speeds up plugin-setup scans. See `InstallSafeDITool` for the +// companion command plugin that downloads the binary. + +#if canImport(XcodeProjectPlugin) + import XcodeProjectPlugin + + extension XcodeProjectPlugin.XcodePluginContext { + /// Hardcoded because Xcode command plugins can't read the package + /// manifest. OK to lag behind the latest release as long as + /// SafeDITool's CLI surface hasn't changed in ways that break older + /// callers — the binary format is forward-compatible within a minor + /// release line. + var safeDIVersion: String { + "2.0.0-beta-4" + } + + /// Hardcoded source repo (forks must update this to point at their + /// own releases for the downloader to work for their users). + var safeDIOrigin: URL { + URL(string: "https://github.com/dfed/SafeDI")! + } + + var safediFolder: URL { + xcodeProject.directoryURL.appending(component: ".safedi") + } + + var expectedToolFolder: URL { + safediFolder.appending(component: safeDIVersion) + } + + var expectedToolLocation: URL { + expectedToolFolder.appending(component: "safeditool") + } + + var downloadedToolLocation: URL? { + guard FileManager.default.fileExists(atPath: expectedToolLocation.path(percentEncoded: false)) else { return nil } + return expectedToolLocation + } + } +#endif + +extension PackagePlugin.PluginContext { + /// Pulls the SafeDI version from the resolved package graph. Returns + /// `nil` when the package is consumed via a non-versioned reference + /// (local path, root package) — in those cases the downloader can't + /// pick a matching release and the build plugin skips the prebuilt + /// path entirely. + var safeDIVersion: String? { + guard let safeDIOrigin = package.dependencies.first(where: { $0.package.displayName == "SafeDI" })?.package.origin else { + return nil + } + switch safeDIOrigin { + case let .repository(_, displayVersion, _): + // As of Xcode 16.0 Beta 6, the display version is of the form "Optional(version)". + guard let versionMatch = try? /Optional\((.*?)\)|^(.*?)$/.firstMatch(in: displayVersion), + let version = versionMatch.output.1 ?? versionMatch.output.2 + else { + return nil + } + return String(version) + case .registry, .root, .local: + fallthrough + @unknown default: + return nil + } + } + + var safediFolder: URL { + package.directoryURL.appending(component: ".safedi") + } + + var expectedToolFolder: URL? { + guard let safeDIVersion else { return nil } + return safediFolder.appending(component: safeDIVersion) + } + + var expectedToolLocation: URL? { + expectedToolFolder?.appending(component: "safeditool") + } + + var downloadedToolLocation: URL? { + guard let expectedToolLocation, + FileManager.default.fileExists(atPath: expectedToolLocation.path(percentEncoded: false)) + else { return nil } + return expectedToolLocation + } +} // MARK: - CSV Writing From 3e0bbfe132e064ac91cc57b6b06a7b0e97aa1dd5 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 20 Apr 2026 07:05:07 -0700 Subject: [PATCH 02/15] Install: verify HTTP 2xx before installing downloaded tool (codex P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `URLSession.download(for:)` resolves with `.success` for HTTP error responses (404, 500, etc.) — the downloaded file contains the error body, not the tool. Previously we'd `chmod +x` and move that body into `.safedi//safeditool`, so the next build would try to execute an HTML/JSON error page and fail opaquely. Cast the `URLResponse` to `HTTPURLResponse` and require a 2xx status before installing. On non-2xx: clean up the temp file and throw a `DownloadFailedError` with the URL and status code so users can see what went wrong (typically: the hardcoded SafeDI version in the Xcode plugin path has drifted past what's published on GitHub). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../InstallSafeDITool/InstallSafeDITool.swift | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/Plugins/InstallSafeDITool/InstallSafeDITool.swift b/Plugins/InstallSafeDITool/InstallSafeDITool.swift index 8276d1bb..59536910 100644 --- a/Plugins/InstallSafeDITool/InstallSafeDITool.swift +++ b/Plugins/InstallSafeDITool/InstallSafeDITool.swift @@ -148,9 +148,21 @@ private func downloadTool( version, toolName, ) - let (downloadedURL, _) = try await URLSession.shared.download( + let (downloadedURL, response) = try await URLSession.shared.download( for: URLRequest(url: githubDownloadURL), ) + // `URLSession.download(for:)` reports success for HTTP error pages + // (404, 500, etc.), so without this check we'd `chmod +x` and install + // the error body as the tool — the next build would fail opaquely. + if let httpResponse = response as? HTTPURLResponse, + !(200..<300).contains(httpResponse.statusCode) + { + try? FileManager.default.removeItem(at: downloadedURL) + throw DownloadFailedError( + url: githubDownloadURL, + statusCode: httpResponse.statusCode, + ) + } let downloadedFileAttributes = try FileManager.default.attributesOfItem(atPath: downloadedURL.path(percentEncoded: false)) guard let currentPermissions = downloadedFileAttributes[.posixPermissions] as? NSNumber, chmod(downloadedURL.path(percentEncoded: false), mode_t(currentPermissions.uint32Value) | S_IXUSR | S_IXGRP | S_IXOTH) == 0 @@ -183,6 +195,14 @@ private struct UnsupportedArchitectureError: Error, CustomStringConvertible { } } +private struct DownloadFailedError: Error, CustomStringConvertible { + let url: URL + let statusCode: Int + var description: String { + "Failed to download SafeDITool from \(url.absoluteString): HTTP \(statusCode). Verify the SafeDI version matches a published release." + } +} + private struct CouldNotMakeExecutableError: Error, CustomStringConvertible { let path: String var description: String { From 38768b72417eb41190cf9e2cb09cf899c09d3867 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 20 Apr 2026 07:36:51 -0700 Subject: [PATCH 03/15] Install: move error handling inside Task to avoid Swift 6 data race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Xcode 26.4's Swift 6 strict concurrency rejected the pattern of capturing a `var capturedError: Error?` across a `Task.detached` — the captured reference is non-Sendable. Match the 1.5.4 approach: handle errors inline inside the Task (Diagnostics.error + exit(1)) so nothing crosses the Sendable boundary. Semantically identical, zero-copy fix. --- Plugins/InstallSafeDITool/InstallSafeDITool.swift | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/Plugins/InstallSafeDITool/InstallSafeDITool.swift b/Plugins/InstallSafeDITool/InstallSafeDITool.swift index 59536910..c5b44469 100644 --- a/Plugins/InstallSafeDITool/InstallSafeDITool.swift +++ b/Plugins/InstallSafeDITool/InstallSafeDITool.swift @@ -95,10 +95,12 @@ struct InstallSafeDITool: CommandPlugin { // `XcodeCommandPlugin.performCommand` is synchronous. Bridge to // the async `downloadTool` helper via a dispatch group so the - // command doesn't return until the download finishes. + // command doesn't return until the download finishes. Errors + // are reported and the process exits inside the task — avoids + // capturing a mutable `Error?` across a Sendable boundary, + // which Swift 6 rejects as a data race. let dispatchGroup = DispatchGroup() dispatchGroup.enter() - var capturedError: Error? Task.detached { defer { dispatchGroup.leave() } do { @@ -110,14 +112,11 @@ struct InstallSafeDITool: CommandPlugin { safediFolder: safediFolder, ) } catch { - capturedError = error + Diagnostics.error("\(error)") + exit(1) } } dispatchGroup.wait() - if let capturedError { - Diagnostics.error("\(capturedError)") - exit(1) - } } } #endif From 7251d2379b1440e2c92d17d383b8ca17cb212b51 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 20 Apr 2026 09:37:42 -0700 Subject: [PATCH 04/15] Install: host-aware artifact choice + verify cached tool runs (codex P1 + P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review concerns on #273: 1. **P1: downloader always picked a macOS asset.** `downloadTool` branched only on CPU architecture, so a Linux host running `safedi-install-tool` downloaded `SafeDITool-macos-` and installed a Mach-O binary into `.safedi//safeditool`. The build plugin then preferred that path and every subsequent build failed with an exec-format error. Fix: branch on both OS and architecture (macOS or Linux, arm64 or x86_64); otherwise throw `UnsupportedHostError`. 2. **P2: build plugin trusted any `.safedi//safeditool` without verifying it runs.** A file at that path from a wrong- platform install (or a corrupted download that missed the exec bit) would become the authoritative tool — build commands invoked it, failed opaquely, and builds stayed broken until users manually removed the cached file. Fix: new `verifiedDownloadedToolLocation(_:)` helper in `Plugins/Shared.swift` launches the binary with `--version` and returns the URL only when the process exits with status 0. Launch/exec failures route the plugin back to the SPM-provided tool (with the existing install-command diagnostic in place). Both call sites in the build plugin (SPM `createBuildCommands` and `XcodeBuildToolPlugin.createBuildCommands`) now gate the downloaded-tool path through the verifier. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../InstallSafeDITool/InstallSafeDITool.swift | 30 ++++++++++++++----- .../SafeDIGenerateDependencyTree.swift | 27 ++++++++++------- Plugins/Shared.swift | 22 ++++++++++++++ 3 files changed, 62 insertions(+), 17 deletions(-) diff --git a/Plugins/InstallSafeDITool/InstallSafeDITool.swift b/Plugins/InstallSafeDITool/InstallSafeDITool.swift index c5b44469..d3ac2d5c 100644 --- a/Plugins/InstallSafeDITool/InstallSafeDITool.swift +++ b/Plugins/InstallSafeDITool/InstallSafeDITool.swift @@ -133,12 +133,28 @@ private func downloadTool( expectedToolLocation: URL, safediFolder: URL, ) async throws { - #if arch(arm64) - let toolName = "SafeDITool-macos-arm64" - #elseif arch(x86_64) - let toolName = "SafeDITool-macos-x86_64" + // GitHub releases publish `SafeDITool--` assets. Pick the + // one matching the host the installer runs on — consumers invoke + // this command on their dev machine, and the resulting binary has + // to run on that same host later when the build plugin launches it. + #if os(macOS) + #if arch(arm64) + let toolName = "SafeDITool-macos-arm64" + #elseif arch(x86_64) + let toolName = "SafeDITool-macos-x86_64" + #else + throw UnsupportedHostError() + #endif + #elseif os(Linux) + #if arch(arm64) + let toolName = "SafeDITool-linux-arm64" + #elseif arch(x86_64) + let toolName = "SafeDITool-linux-x86_64" + #else + throw UnsupportedHostError() + #endif #else - throw UnsupportedArchitectureError() + throw UnsupportedHostError() #endif let githubDownloadURL = originURL.appending( @@ -188,9 +204,9 @@ private func downloadTool( } } -private struct UnsupportedArchitectureError: Error, CustomStringConvertible { +private struct UnsupportedHostError: Error, CustomStringConvertible { var description: String { - "Unsupported host architecture for SafeDITool download." + "Unsupported host OS/architecture for SafeDITool download. Supported: macOS and Linux on arm64 or x86_64." } } diff --git a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift index 6b759a76..66758e6d 100644 --- a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift @@ -32,12 +32,16 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { } // Prefer the user-downloaded prebuilt tool at `.safedi//safeditool` - // when available. Two reasons: (1) it sidesteps the `${BUILD_DIR}`-in-tool- - // path plugin-setup problem for Xcode-driven `sourceBuild` builds, and - // (2) the release build is ~15× faster than the debug build that SPM - // produces for source-built tools. + // when available AND runnable on this host. Two reasons: (1) it sidesteps + // the `${BUILD_DIR}`-in-tool-path plugin-setup problem for Xcode-driven + // `sourceBuild` builds, and (2) the release build is ~15× faster than + // the debug build that SPM produces for source-built tools. + // + // Falling back when the file exists but can't execute (wrong platform, + // corrupted, missing exec bit) avoids an opaque-launch-failure loop + // where every build keeps invoking the broken binary. let tool: URL - if let downloaded = context.downloadedToolLocation { + if let downloaded = verifiedDownloadedToolLocation(context.downloadedToolLocation) { tool = downloaded } else { tool = try context.tool(named: "SafeDITool").url @@ -210,13 +214,16 @@ extension Target { target: XcodeProjectPlugin.XcodeTarget, ) throws -> [PackagePlugin.Command] { // Prefer the user-downloaded prebuilt tool at - // `.safedi//safeditool` when available. The SPM-provided - // tool path arrives as `${BUILD_DIR}/${CONFIGURATION}/SafeDITool` - // in Xcode, which can't be executed at plugin-setup time and - // forces the regex-based PluginScanner fallback. + // `.safedi//safeditool` when available AND runnable on + // this host. The SPM-provided tool path arrives as + // `${BUILD_DIR}/${CONFIGURATION}/SafeDITool` in Xcode, which + // can't be executed at plugin-setup time and forces the + // regex-based PluginScanner fallback. Verifying runnability + // avoids trusting a cached binary that can't actually launch + // (wrong platform, corrupted, missing exec bit). let tool: URL let runsRealScan: Bool - if let downloaded = context.downloadedToolLocation { + if let downloaded = verifiedDownloadedToolLocation(context.downloadedToolLocation) { tool = downloaded runsRealScan = true } else { diff --git a/Plugins/Shared.swift b/Plugins/Shared.swift index e58cbb8c..4e89bd83 100644 --- a/Plugins/Shared.swift +++ b/Plugins/Shared.swift @@ -116,6 +116,28 @@ extension PackagePlugin.PluginContext { } } +/// Verifies a cached SafeDITool binary actually runs on the host by +/// launching it with `--version` and checking it exits cleanly. Returns +/// the URL when the binary is usable, or `nil` when it can't launch +/// (wrong platform, corrupted, missing exec bit, etc.) so callers can +/// fall back to the SPM-provided tool. +func verifiedDownloadedToolLocation(_ toolURL: URL?) -> URL? { + guard let toolURL else { return nil } + let process = Process() + process.executableURL = toolURL + process.arguments = ["--version"] + process.standardOutput = Pipe() + process.standardError = Pipe() + do { + try process.run() + } catch { + return nil + } + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + return toolURL +} + // MARK: - CSV Writing func writeInputSwiftFilesCSV( From 44ff16a5177e26188af3901b973f5b2b91a07047 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 20 Apr 2026 09:48:57 -0700 Subject: [PATCH 05/15] Install: scope InstallSafeDITool to Xcode-only, drop SPM install path swift build users already get the prebuilt binary through the default `prebuilt` trait, and `--traits sourceBuild` builds SafeDITool from source on purpose. The install plugin only exists to work around Xcode's `${BUILD_DIR}`-in-tool-path problem, so the SPM-side installer and macOS/Linux host detection weren't pulling their weight. The SPM `CommandPlugin.performCommand` stays (Package.swift's .command capability requires CommandPlugin conformance) but now just emits an error pointing users at the traits. The downloaded-tool lookup in the SPM build plugin path is removed; the Xcode build plugin still prefers it. Removes the Linux arch branches and the FoundationNetworking import. Co-Authored-By: Claude Opus 4.7 (1M context) --- Package.swift | 9 +- .../InstallSafeDITool/InstallSafeDITool.swift | 73 ++++------------ .../SafeDIGenerateDependencyTree.swift | 33 +------ Plugins/Shared.swift | 87 +++++-------------- 4 files changed, 41 insertions(+), 161 deletions(-) diff --git a/Package.swift b/Package.swift index 40939f54..7fef37f2 100644 --- a/Package.swift +++ b/Package.swift @@ -106,12 +106,9 @@ let package = Package( ), // Downloads the prebuilt SafeDITool release binary into - // `/.safedi//safeditool`. The build plugin prefers - // that path over the SPM-provided tool — it avoids the - // `${BUILD_DIR}`-in-tool-path problem that forces Xcode sourceBuild - // users onto the regex-based `PluginScanner` fallback, and it avoids - // the ~15× slower debug build that SPM produces when sourceBuild is - // active. + // `/.safedi//safeditool`. Xcode-only — avoids + // the `${BUILD_DIR}`-in-tool-path problem that forces Xcode users + // onto the regex-based `PluginScanner` fallback. .plugin( name: "InstallSafeDITool", capability: .command( diff --git a/Plugins/InstallSafeDITool/InstallSafeDITool.swift b/Plugins/InstallSafeDITool/InstallSafeDITool.swift index d3ac2d5c..537435c0 100644 --- a/Plugins/InstallSafeDITool/InstallSafeDITool.swift +++ b/Plugins/InstallSafeDITool/InstallSafeDITool.swift @@ -19,63 +19,28 @@ // SOFTWARE. import Foundation -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif import PackagePlugin /// Downloads the prebuilt SafeDITool release binary for the current SafeDI -/// version into `/.safedi//safeditool` (or the Xcode -/// project's equivalent directory). The build plugin prefers this path over -/// the SPM-provided tool because: +/// version into `/.safedi//safeditool`. The Xcode +/// build plugin prefers this path over the SPM-provided tool because +/// `context.tool(named:)` returns an unresolved +/// `${BUILD_DIR}/${CONFIGURATION}/SafeDITool` template path at plugin-setup +/// time that can't be executed, forcing a fall back to the lossy +/// regex-based `PluginScanner`. A downloaded prebuilt gives the +/// plugin-setup scan the real parser's output. /// -/// 1. **Xcode + sourceBuild trait**: `context.tool(named:)` returns an -/// unresolved `${BUILD_DIR}/${CONFIGURATION}/SafeDITool` template path -/// at plugin-setup time that can't be executed, forcing a fall back to -/// the lossy regex-based `PluginScanner`. A downloaded prebuilt gives -/// the plugin-setup scan the real parser's output. -/// 2. **swift build + sourceBuild trait**: SafeDITool is built in DEBUG -/// config, which is ~15× slower than the release binary. Downloading -/// the prebuilt release restores prod-speed codegen. +/// This plugin is Xcode-only. Users who build via `swift build` already get +/// the prebuilt binary through the default `prebuilt` trait (or +/// intentionally build from source with `--traits sourceBuild`). @main struct InstallSafeDITool: CommandPlugin { func performCommand( - context: PackagePlugin.PluginContext, + context _: PackagePlugin.PluginContext, arguments _: [String], ) async throws { - guard let safeDIOrigin = context.package.dependencies.first(where: { $0.package.displayName == "SafeDI" })?.package.origin else { - Diagnostics.error("No package origin found for SafeDI package.") - exit(1) - } - guard let version = context.safeDIVersion, - let expectedToolFolder = context.expectedToolFolder, - let expectedToolLocation = context.expectedToolLocation - else { - Diagnostics.error("Could not extract version for SafeDI. The install plugin only works when SafeDI is consumed via a versioned release (not a local or root package reference).") - exit(1) - } - - switch safeDIOrigin { - case let .repository(url, _, _): - guard let originURL = URL(string: url)?.deletingPathExtension() else { - Diagnostics.error("No package URL found for SafeDI package.") - exit(1) - } - try await downloadTool( - originURL: originURL, - version: version, - expectedToolFolder: expectedToolFolder, - expectedToolLocation: expectedToolLocation, - safediFolder: context.safediFolder, - ) - - case .registry, .root, .local: - fallthrough - - @unknown default: - Diagnostics.error("Cannot download SafeDITool from \(safeDIOrigin) — downloading only works when using a versioned release of SafeDI.") - exit(1) - } + Diagnostics.error("safedi-install-tool is an Xcode-only command plugin. swift build users get the prebuilt binary via the default `prebuilt` trait, and `--traits sourceBuild` builds SafeDITool from source on purpose.") + exit(1) } } @@ -133,7 +98,7 @@ private func downloadTool( expectedToolLocation: URL, safediFolder: URL, ) async throws { - // GitHub releases publish `SafeDITool--` assets. Pick the + // GitHub releases publish `SafeDITool-macos-` assets. Pick the // one matching the host the installer runs on — consumers invoke // this command on their dev machine, and the resulting binary has // to run on that same host later when the build plugin launches it. @@ -145,14 +110,6 @@ private func downloadTool( #else throw UnsupportedHostError() #endif - #elseif os(Linux) - #if arch(arm64) - let toolName = "SafeDITool-linux-arm64" - #elseif arch(x86_64) - let toolName = "SafeDITool-linux-x86_64" - #else - throw UnsupportedHostError() - #endif #else throw UnsupportedHostError() #endif @@ -206,7 +163,7 @@ private func downloadTool( private struct UnsupportedHostError: Error, CustomStringConvertible { var description: String { - "Unsupported host OS/architecture for SafeDITool download. Supported: macOS and Linux on arm64 or x86_64." + "Unsupported host OS/architecture for SafeDITool download. Supported: macOS on arm64 or x86_64." } } diff --git a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift index 66758e6d..7bed6c75 100644 --- a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift @@ -31,30 +31,7 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { return [] } - // Prefer the user-downloaded prebuilt tool at `.safedi//safeditool` - // when available AND runnable on this host. Two reasons: (1) it sidesteps - // the `${BUILD_DIR}`-in-tool-path plugin-setup problem for Xcode-driven - // `sourceBuild` builds, and (2) the release build is ~15× faster than - // the debug build that SPM produces for source-built tools. - // - // Falling back when the file exists but can't execute (wrong platform, - // corrupted, missing exec bit) avoids an opaque-launch-failure loop - // where every build keeps invoking the broken binary. - let tool: URL - if let downloaded = verifiedDownloadedToolLocation(context.downloadedToolLocation) { - tool = downloaded - } else { - tool = try context.tool(named: "SafeDITool").url - if let version = context.safeDIVersion { - Diagnostics.warning(""" - SafeDI is running a locally-built SafeDITool in the debug configuration, \ - which is ~15× slower than the prebuilt release binary. To install the \ - release binary for version \(version), run: - - \tswift package --package-path "\(context.package.directoryURL.path(percentEncoded: false))" --allow-network-connections all --allow-writing-to-package-directory safedi-install-tool - """) - } - } + let tool = try context.tool(named: "SafeDITool").url let outputDirectory = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutput") let targetSwiftFiles = sourceTarget.sourceFiles(withSuffix: ".swift").map(\.url) let dependenciesSourceFiles = sourceTarget @@ -233,12 +210,8 @@ extension Target { SafeDI's build-tool plugin is falling back to a regex-based output \ scanner because the SPM-provided SafeDITool path contains unresolved \ Xcode build variables at plugin-setup time. To install the prebuilt \ - SafeDITool binary for version \(context.safeDIVersion), run: - - \tswift package --package-path "\(context.xcodeProject.directoryURL.path(percentEncoded: false))" --allow-network-connections all --allow-writing-to-package-directory safedi-install-tool - - You may need to create a Package.swift at that path first, or run \ - the command from a directory that contains one. + SafeDITool binary for version \(context.safeDIVersion), right-click \ + the project in Xcode and choose SafeDI → Safedi Install Tool. """) } let inputSwiftFiles = target diff --git a/Plugins/Shared.swift b/Plugins/Shared.swift index 4e89bd83..4d80a7e5 100644 --- a/Plugins/Shared.swift +++ b/Plugins/Shared.swift @@ -29,7 +29,7 @@ import PackagePlugin // the `sourceBuild` trait is active — the plugin-setup phase can't execute // the tool. A user-downloaded prebuilt at the known location sidesteps that // and also speeds up plugin-setup scans. See `InstallSafeDITool` for the -// companion command plugin that downloads the binary. +// companion Xcode command plugin that downloads the binary. #if canImport(XcodeProjectPlugin) import XcodeProjectPlugin @@ -67,76 +67,29 @@ import PackagePlugin return expectedToolLocation } } -#endif -extension PackagePlugin.PluginContext { - /// Pulls the SafeDI version from the resolved package graph. Returns - /// `nil` when the package is consumed via a non-versioned reference - /// (local path, root package) — in those cases the downloader can't - /// pick a matching release and the build plugin skips the prebuilt - /// path entirely. - var safeDIVersion: String? { - guard let safeDIOrigin = package.dependencies.first(where: { $0.package.displayName == "SafeDI" })?.package.origin else { - return nil - } - switch safeDIOrigin { - case let .repository(_, displayVersion, _): - // As of Xcode 16.0 Beta 6, the display version is of the form "Optional(version)". - guard let versionMatch = try? /Optional\((.*?)\)|^(.*?)$/.firstMatch(in: displayVersion), - let version = versionMatch.output.1 ?? versionMatch.output.2 - else { - return nil - } - return String(version) - case .registry, .root, .local: - fallthrough - @unknown default: + /// Verifies a cached SafeDITool binary actually runs on the host by + /// launching it with `--version` and checking it exits cleanly. Returns + /// the URL when the binary is usable, or `nil` when it can't launch + /// (wrong platform, corrupted, missing exec bit, etc.) so callers can + /// fall back to the SPM-provided tool. + func verifiedDownloadedToolLocation(_ toolURL: URL?) -> URL? { + guard let toolURL else { return nil } + let process = Process() + process.executableURL = toolURL + process.arguments = ["--version"] + process.standardOutput = Pipe() + process.standardError = Pipe() + do { + try process.run() + } catch { return nil } + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + return toolURL } - - var safediFolder: URL { - package.directoryURL.appending(component: ".safedi") - } - - var expectedToolFolder: URL? { - guard let safeDIVersion else { return nil } - return safediFolder.appending(component: safeDIVersion) - } - - var expectedToolLocation: URL? { - expectedToolFolder?.appending(component: "safeditool") - } - - var downloadedToolLocation: URL? { - guard let expectedToolLocation, - FileManager.default.fileExists(atPath: expectedToolLocation.path(percentEncoded: false)) - else { return nil } - return expectedToolLocation - } -} - -/// Verifies a cached SafeDITool binary actually runs on the host by -/// launching it with `--version` and checking it exits cleanly. Returns -/// the URL when the binary is usable, or `nil` when it can't launch -/// (wrong platform, corrupted, missing exec bit, etc.) so callers can -/// fall back to the SPM-provided tool. -func verifiedDownloadedToolLocation(_ toolURL: URL?) -> URL? { - guard let toolURL else { return nil } - let process = Process() - process.executableURL = toolURL - process.arguments = ["--version"] - process.standardOutput = Pipe() - process.standardError = Pipe() - do { - try process.run() - } catch { - return nil - } - process.waitUntilExit() - guard process.terminationStatus == 0 else { return nil } - return toolURL -} +#endif // MARK: - CSV Writing From 3c3150f9f91b373f9423cb4e68f420a18d75195b Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 20 Apr 2026 10:58:36 -0700 Subject: [PATCH 06/15] Install: gate downloadTool body on macOS so Linux plugin compiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin target can't be excluded from the build — Package.swift declares it unconditionally — so it has to compile on every platform SPM supports, including Linux. Before this fix, removing the Linux host detection and `FoundationNetworking` import left the `URLSession.shared.download(for:)` call referencing APIs that don't exist in Linux Foundation, breaking the Linux CI build. Wrap the entire download body in `#if os(macOS)` with a throwing `#else` branch. At runtime, non-macOS hosts still get `UnsupportedHostError`; at compile time, Linux never sees `URLSession.shared`, `URLRequest`, or `.posixPermissions` so the compiler stays happy. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../InstallSafeDITool/InstallSafeDITool.swift | 107 ++++++++++-------- 1 file changed, 57 insertions(+), 50 deletions(-) diff --git a/Plugins/InstallSafeDITool/InstallSafeDITool.swift b/Plugins/InstallSafeDITool/InstallSafeDITool.swift index 537435c0..89b77c60 100644 --- a/Plugins/InstallSafeDITool/InstallSafeDITool.swift +++ b/Plugins/InstallSafeDITool/InstallSafeDITool.swift @@ -91,6 +91,13 @@ struct InstallSafeDITool: CommandPlugin { /// per-version location. Also writes a `.gitignore` inside `.safedi/` /// (on first run) that excludes the per-version binaries from source /// control — the binary is per-machine and shouldn't be committed. +/// +/// Entire body gated on macOS — Linux Foundation doesn't ship +/// `URLSession.shared` without `FoundationNetworking`, and the Xcode +/// template-path problem this plugin exists to work around is +/// macOS-only anyway. The whole plugin can't be excluded from the +/// build (the `.command` target in Package.swift is unconditional), +/// so non-macOS compilation produces a function that just throws. private func downloadTool( originURL: URL, version: String, @@ -98,11 +105,11 @@ private func downloadTool( expectedToolLocation: URL, safediFolder: URL, ) async throws { - // GitHub releases publish `SafeDITool-macos-` assets. Pick the - // one matching the host the installer runs on — consumers invoke - // this command on their dev machine, and the resulting binary has - // to run on that same host later when the build plugin launches it. #if os(macOS) + // GitHub releases publish `SafeDITool-macos-` assets. Pick the + // one matching the host the installer runs on — consumers invoke + // this command on their dev machine, and the resulting binary has + // to run on that same host later when the build plugin launches it. #if arch(arm64) let toolName = "SafeDITool-macos-arm64" #elseif arch(x86_64) @@ -110,55 +117,55 @@ private func downloadTool( #else throw UnsupportedHostError() #endif - #else - throw UnsupportedHostError() - #endif - let githubDownloadURL = originURL.appending( - components: "releases", - "download", - version, - toolName, - ) - let (downloadedURL, response) = try await URLSession.shared.download( - for: URLRequest(url: githubDownloadURL), - ) - // `URLSession.download(for:)` reports success for HTTP error pages - // (404, 500, etc.), so without this check we'd `chmod +x` and install - // the error body as the tool — the next build would fail opaquely. - if let httpResponse = response as? HTTPURLResponse, - !(200..<300).contains(httpResponse.statusCode) - { - try? FileManager.default.removeItem(at: downloadedURL) - throw DownloadFailedError( - url: githubDownloadURL, - statusCode: httpResponse.statusCode, + let githubDownloadURL = originURL.appending( + components: "releases", + "download", + version, + toolName, ) - } - let downloadedFileAttributes = try FileManager.default.attributesOfItem(atPath: downloadedURL.path(percentEncoded: false)) - guard let currentPermissions = downloadedFileAttributes[.posixPermissions] as? NSNumber, - chmod(downloadedURL.path(percentEncoded: false), mode_t(currentPermissions.uint32Value) | S_IXUSR | S_IXGRP | S_IXOTH) == 0 - else { - throw CouldNotMakeExecutableError(path: downloadedURL.path(percentEncoded: false)) - } - try FileManager.default.createDirectory(at: expectedToolFolder, withIntermediateDirectories: true) - if FileManager.default.fileExists(atPath: expectedToolLocation.path(percentEncoded: false)) { - try FileManager.default.removeItem(at: expectedToolLocation) - } - try FileManager.default.moveItem(at: downloadedURL, to: expectedToolLocation) - - let gitIgnoreLocation = safediFolder.appending(component: ".gitignore") - if !FileManager.default.fileExists(atPath: gitIgnoreLocation.path(percentEncoded: false)) { - // Each version gets its own subfolder (`/safeditool`) so - // the glob `*/safeditool` catches every installed binary. - try """ - */\(expectedToolLocation.lastPathComponent) - """.write( - to: gitIgnoreLocation, - atomically: true, - encoding: .utf8, + let (downloadedURL, response) = try await URLSession.shared.download( + for: URLRequest(url: githubDownloadURL), ) - } + // `URLSession.download(for:)` reports success for HTTP error pages + // (404, 500, etc.), so without this check we'd `chmod +x` and install + // the error body as the tool — the next build would fail opaquely. + if let httpResponse = response as? HTTPURLResponse, + !(200..<300).contains(httpResponse.statusCode) + { + try? FileManager.default.removeItem(at: downloadedURL) + throw DownloadFailedError( + url: githubDownloadURL, + statusCode: httpResponse.statusCode, + ) + } + let downloadedFileAttributes = try FileManager.default.attributesOfItem(atPath: downloadedURL.path(percentEncoded: false)) + guard let currentPermissions = downloadedFileAttributes[.posixPermissions] as? NSNumber, + chmod(downloadedURL.path(percentEncoded: false), mode_t(currentPermissions.uint32Value) | S_IXUSR | S_IXGRP | S_IXOTH) == 0 + else { + throw CouldNotMakeExecutableError(path: downloadedURL.path(percentEncoded: false)) + } + try FileManager.default.createDirectory(at: expectedToolFolder, withIntermediateDirectories: true) + if FileManager.default.fileExists(atPath: expectedToolLocation.path(percentEncoded: false)) { + try FileManager.default.removeItem(at: expectedToolLocation) + } + try FileManager.default.moveItem(at: downloadedURL, to: expectedToolLocation) + + let gitIgnoreLocation = safediFolder.appending(component: ".gitignore") + if !FileManager.default.fileExists(atPath: gitIgnoreLocation.path(percentEncoded: false)) { + // Each version gets its own subfolder (`/safeditool`) so + // the glob `*/safeditool` catches every installed binary. + try """ + */\(expectedToolLocation.lastPathComponent) + """.write( + to: gitIgnoreLocation, + atomically: true, + encoding: .utf8, + ) + } + #else + throw UnsupportedHostError() + #endif } private struct UnsupportedHostError: Error, CustomStringConvertible { From 1f391474859d290c2aefd7416ededb1a4d467afc Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 20 Apr 2026 11:39:50 -0700 Subject: [PATCH 07/15] =?UTF-8?q?Install:=20address=20self-review=20P1/P2s?= =?UTF-8?q?=20=E2=80=94=20atomic=20move,=20version-match,=20better=20error?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **Atomic move**: replace `removeItem` + `moveItem` with `FileManager.replaceItemAt`. The old two-step sequence is a race under concurrent installs (e.g., a dev and CI running simultaneously): two processes can both `removeItem`, then only one `moveItem` succeeds because the destination exists again. `replaceItemAt` is atomic on HFS+/APFS. - **Version match in verification**: `verifiedDownloadedToolLocation` now captures `--version` stdout and compares against `expectedVersion`. Catches the "stale binary from an older SafeDI left in `.safedi/`" case — a tool that launches cleanly but reports a mismatched version would previously be trusted indefinitely. - **Wrap URLSession errors**: `URLError` stringifies to a useless generic ("The operation couldn't be completed."). `DownloadRequestFailedError` prepends the URL and passes through the underlying message so offline / DNS / release-tag-missing cases surface something actionable. - **Manual**: update the install section to (a) direct users to Xcode's right-click → plugin menu (the CLI invocation no longer works since the SPM path errors out), and (b) tell users to commit `.safedi/.gitignore` so per-machine binaries stay ignored across the team. - **Comment cleanup**: the `Task.detached` explanation now correctly notes that `exit(1)` on failure skips the deferred `leave()` — it's a hard process kill, not a graceful release. Co-Authored-By: Claude Opus 4.7 (1M context) --- Documentation/Manual.md | 15 ++---- .../InstallSafeDITool/InstallSafeDITool.swift | 46 +++++++++++++++---- .../SafeDIGenerateDependencyTree.swift | 2 +- Plugins/Shared.swift | 26 ++++++++--- 4 files changed, 60 insertions(+), 29 deletions(-) diff --git a/Documentation/Manual.md b/Documentation/Manual.md index 6ed8fa33..9cf87b64 100644 --- a/Documentation/Manual.md +++ b/Documentation/Manual.md @@ -44,20 +44,13 @@ The `additionalDirectoriesToInclude` parameter specifies folders outside of your ##### Installing the prebuilt SafeDITool binary -If you see a build warning that starts with **"SafeDI's build-tool plugin is falling back to a regex-based output scanner..."** — or **"SafeDI is running a locally-built SafeDITool in the debug configuration..."** — installing the prebuilt tool fixes it: +If you see a build warning that starts with **"SafeDI's build-tool plugin is falling back to a regex-based output scanner..."** — installing the prebuilt tool fixes it. -``` -swift package --package-path "/path/to/YourXcodeProject" \ - --allow-network-connections all \ - --allow-writing-to-package-directory \ - safedi-install-tool -``` - -This downloads the prebuilt SafeDITool release binary for your SafeDI version into `.safedi//safeditool` next to your `.xcodeproj`. The build plugin picks it up automatically on subsequent builds. +In Xcode, right-click your project in the navigator and choose **SafeDI → Safedi Install Tool**. Approve the network-access and write-to-package-directory prompts. Xcode runs the command and downloads the prebuilt SafeDITool release binary for your SafeDI version into `.safedi//safeditool` next to your `.xcodeproj`. The build plugin picks it up automatically on subsequent builds. -**Why this step is required in Xcode:** Swift package build-tool plugins are told the SafeDITool location via an Xcode build-variable template (`${BUILD_DIR}/${CONFIGURATION}/SafeDITool`) whose values aren't exposed to the plugin's process environment at plugin-setup time. SafeDI's plugin therefore can't execute the real parser during setup and falls back to a regex-based output scanner. A prebuilt binary at a known path sidesteps the template entirely. The install command is a one-time step per project (re-run it after a SafeDI version bump if the warning reappears). +**Why this step is required in Xcode:** Swift package build-tool plugins are told the SafeDITool location via an Xcode build-variable template (`${BUILD_DIR}/${CONFIGURATION}/SafeDITool`) whose values aren't exposed to the plugin's process environment at plugin-setup time. SafeDI's plugin therefore can't execute the real parser during setup and falls back to a regex-based output scanner. A prebuilt binary at a known path sidesteps the template entirely. The install command is a one-time step per project (re-run it after a SafeDI version bump if the warning reappears). The plugin only runs inside Xcode — `swift build` users get the prebuilt binary through the default `prebuilt` trait already. -**What to commit:** the command writes a `.safedi/.gitignore` that excludes the per-version binary from source control — the binary is host-specific (arm64 vs x86_64) and per-version, so it shouldn't be committed. Developers new to the project re-run the install command once after cloning. +**What to commit:** the install command writes `.safedi/.gitignore` on first run with the single glob `*/safeditool`, which ignores `.safedi//safeditool` on every machine. **Commit `.safedi/.gitignore` itself.** The binary it ignores is host-specific (arm64 vs x86_64) and per-version, so it's meant to be re-downloaded per-machine. Developers new to the project re-run the install command once after cloning. #### Swift package diff --git a/Plugins/InstallSafeDITool/InstallSafeDITool.swift b/Plugins/InstallSafeDITool/InstallSafeDITool.swift index 89b77c60..b1335232 100644 --- a/Plugins/InstallSafeDITool/InstallSafeDITool.swift +++ b/Plugins/InstallSafeDITool/InstallSafeDITool.swift @@ -59,11 +59,12 @@ struct InstallSafeDITool: CommandPlugin { let safeDIOrigin = context.safeDIOrigin // `XcodeCommandPlugin.performCommand` is synchronous. Bridge to - // the async `downloadTool` helper via a dispatch group so the - // command doesn't return until the download finishes. Errors - // are reported and the process exits inside the task — avoids - // capturing a mutable `Error?` across a Sendable boundary, - // which Swift 6 rejects as a data race. + // the async `downloadTool` helper via a dispatch group. On + // failure the task reports the diagnostic and calls `exit(1)`, + // which terminates the process — the `defer { dispatchGroup.leave() }` + // never runs in that path, but the hard kill makes that moot. + // The indirection (vs. capturing a mutable `Error?` across + // Sendable) sidesteps Swift 6 data-race diagnostics. let dispatchGroup = DispatchGroup() dispatchGroup.enter() Task.detached { @@ -124,9 +125,19 @@ private func downloadTool( version, toolName, ) - let (downloadedURL, response) = try await URLSession.shared.download( - for: URLRequest(url: githubDownloadURL), - ) + let downloadedURL: URL + let response: URLResponse + do { + (downloadedURL, response) = try await URLSession.shared.download( + for: URLRequest(url: githubDownloadURL), + ) + } catch { + // URLSession errors stringify to a useless Foundation + // generic (`The operation couldn't be completed. ...`). + // Wrap with the URL + the underlying message so offline / + // GitHub-down / DNS cases surface something actionable. + throw DownloadRequestFailedError(url: githubDownloadURL, underlying: error) + } // `URLSession.download(for:)` reports success for HTTP error pages // (404, 500, etc.), so without this check we'd `chmod +x` and install // the error body as the tool — the next build would fail opaquely. @@ -147,9 +158,16 @@ private func downloadTool( } try FileManager.default.createDirectory(at: expectedToolFolder, withIntermediateDirectories: true) if FileManager.default.fileExists(atPath: expectedToolLocation.path(percentEncoded: false)) { - try FileManager.default.removeItem(at: expectedToolLocation) + // `replaceItemAt` is atomic on HFS+/APFS: the new inode replaces + // the old via rename, so concurrent installs can't leave the + // destination in a half-replaced state. `moveItem` + prior + // `removeItem` is a two-step race — two processes interleaving + // could both `removeItem`, then one `moveItem` wins and the + // other fails because the destination exists again. + _ = try FileManager.default.replaceItemAt(expectedToolLocation, withItemAt: downloadedURL) + } else { + try FileManager.default.moveItem(at: downloadedURL, to: expectedToolLocation) } - try FileManager.default.moveItem(at: downloadedURL, to: expectedToolLocation) let gitIgnoreLocation = safediFolder.appending(component: ".gitignore") if !FileManager.default.fileExists(atPath: gitIgnoreLocation.path(percentEncoded: false)) { @@ -188,3 +206,11 @@ private struct CouldNotMakeExecutableError: Error, CustomStringConvertible { "Could not make downloaded file executable: \(path)" } } + +private struct DownloadRequestFailedError: Error, CustomStringConvertible { + let url: URL + let underlying: any Error + var description: String { + "Failed to reach \(url.absoluteString): \(underlying.localizedDescription). Check network connectivity and that the release tag exists." + } +} diff --git a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift index 7bed6c75..09afc3bf 100644 --- a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift @@ -200,7 +200,7 @@ extension Target { // (wrong platform, corrupted, missing exec bit). let tool: URL let runsRealScan: Bool - if let downloaded = verifiedDownloadedToolLocation(context.downloadedToolLocation) { + if let downloaded = verifiedDownloadedToolLocation(context.downloadedToolLocation, expectedVersion: context.safeDIVersion) { tool = downloaded runsRealScan = true } else { diff --git a/Plugins/Shared.swift b/Plugins/Shared.swift index 4d80a7e5..cd7105c5 100644 --- a/Plugins/Shared.swift +++ b/Plugins/Shared.swift @@ -68,17 +68,25 @@ import PackagePlugin } } - /// Verifies a cached SafeDITool binary actually runs on the host by - /// launching it with `--version` and checking it exits cleanly. Returns - /// the URL when the binary is usable, or `nil` when it can't launch - /// (wrong platform, corrupted, missing exec bit, etc.) so callers can - /// fall back to the SPM-provided tool. - func verifiedDownloadedToolLocation(_ toolURL: URL?) -> URL? { + /// Verifies a cached SafeDITool binary both runs on the host AND + /// matches the expected SafeDI version. Launches it with `--version` + /// and compares the trimmed stdout to `expectedVersion`. Returns the + /// URL when the binary is usable and version-matched, or `nil` + /// otherwise — caller falls back to the SPM-provided tool. + /// + /// The version check catches the "stale binary from an earlier + /// SafeDI version left in `.safedi/`" case: the binary might still + /// launch cleanly but produce output incompatible with the current + /// SafeDI release. Without this check, a user bumping SafeDI would + /// silently keep running the old tool until they re-ran the install + /// command or manually cleared `.safedi/`. + func verifiedDownloadedToolLocation(_ toolURL: URL?, expectedVersion: String) -> URL? { guard let toolURL else { return nil } let process = Process() process.executableURL = toolURL process.arguments = ["--version"] - process.standardOutput = Pipe() + let outPipe = Pipe() + process.standardOutput = outPipe process.standardError = Pipe() do { try process.run() @@ -87,6 +95,10 @@ import PackagePlugin } process.waitUntilExit() guard process.terminationStatus == 0 else { return nil } + let data = outPipe.fileHandleForReading.readDataToEndOfFile() + guard let reportedVersion = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + reportedVersion == expectedVersion + else { return nil } return toolURL } #endif From 7823ffd82f5866653140bbb150eb0c36c6897e8b Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Mon, 20 Apr 2026 13:06:53 -0700 Subject: [PATCH 08/15] Apply suggestion from @dfed --- Documentation/Manual.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Documentation/Manual.md b/Documentation/Manual.md index 9cf87b64..cdd166f5 100644 --- a/Documentation/Manual.md +++ b/Documentation/Manual.md @@ -48,7 +48,7 @@ If you see a build warning that starts with **"SafeDI's build-tool plugin is fal In Xcode, right-click your project in the navigator and choose **SafeDI → Safedi Install Tool**. Approve the network-access and write-to-package-directory prompts. Xcode runs the command and downloads the prebuilt SafeDITool release binary for your SafeDI version into `.safedi//safeditool` next to your `.xcodeproj`. The build plugin picks it up automatically on subsequent builds. -**Why this step is required in Xcode:** Swift package build-tool plugins are told the SafeDITool location via an Xcode build-variable template (`${BUILD_DIR}/${CONFIGURATION}/SafeDITool`) whose values aren't exposed to the plugin's process environment at plugin-setup time. SafeDI's plugin therefore can't execute the real parser during setup and falls back to a regex-based output scanner. A prebuilt binary at a known path sidesteps the template entirely. The install command is a one-time step per project (re-run it after a SafeDI version bump if the warning reappears). The plugin only runs inside Xcode — `swift build` users get the prebuilt binary through the default `prebuilt` trait already. +**Why this step is required in Xcode:** Xcode’ implementation of Swift package build-tool plugins does not give plugins access to real tool locations within derived data – paths include placeholders whose values are not available to the plugin. SafeDI's plugin therefore can't execute the real parser during setup and falls back to a regex-based output scanner. Installing the `SafeDITool` to a well-known location allows the plugin to execute the prebuilt tool. The install command is a one-time step per project (re-run it after a SafeDI version bump if the warning reappears). The plugin only runs inside Xcode — `swift build` users get the prebuilt binary through the default `prebuilt` trait already. **What to commit:** the install command writes `.safedi/.gitignore` on first run with the single glob `*/safeditool`, which ignores `.safedi//safeditool` on every machine. **Commit `.safedi/.gitignore` itself.** The binary it ignores is host-specific (arm64 vs x86_64) and per-version, so it's meant to be re-downloaded per-machine. Developers new to the project re-run the install command once after cloning. From 506be4a0e6bfb88ea4709e6882970de4c71a2907 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Mon, 20 Apr 2026 13:07:14 -0700 Subject: [PATCH 09/15] Apply suggestion from @dfed --- Documentation/Manual.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Documentation/Manual.md b/Documentation/Manual.md index cdd166f5..ce88069b 100644 --- a/Documentation/Manual.md +++ b/Documentation/Manual.md @@ -48,7 +48,7 @@ If you see a build warning that starts with **"SafeDI's build-tool plugin is fal In Xcode, right-click your project in the navigator and choose **SafeDI → Safedi Install Tool**. Approve the network-access and write-to-package-directory prompts. Xcode runs the command and downloads the prebuilt SafeDITool release binary for your SafeDI version into `.safedi//safeditool` next to your `.xcodeproj`. The build plugin picks it up automatically on subsequent builds. -**Why this step is required in Xcode:** Xcode’ implementation of Swift package build-tool plugins does not give plugins access to real tool locations within derived data – paths include placeholders whose values are not available to the plugin. SafeDI's plugin therefore can't execute the real parser during setup and falls back to a regex-based output scanner. Installing the `SafeDITool` to a well-known location allows the plugin to execute the prebuilt tool. The install command is a one-time step per project (re-run it after a SafeDI version bump if the warning reappears). The plugin only runs inside Xcode — `swift build` users get the prebuilt binary through the default `prebuilt` trait already. +**Why this step is required in Xcode:** Xcode’ implementation of Swift package build-tool plugins does not give plugins access to real tool locations within derived data – paths include placeholders whose values are not available to the plugin. SafeDI's plugin therefore can’t execute the real parser during setup and falls back to a regex-based output scanner. Installing the `SafeDITool` to a well-known location allows the plugin to execute the prebuilt tool. The install command is a one-time step per project (re-run it after a SafeDI version bump if the warning reappears). The plugin only runs inside Xcode — `swift build` users get the prebuilt binary through the default `prebuilt` trait already. **What to commit:** the install command writes `.safedi/.gitignore` on first run with the single glob `*/safeditool`, which ignores `.safedi//safeditool` on every machine. **Commit `.safedi/.gitignore` itself.** The binary it ignores is host-specific (arm64 vs x86_64) and per-version, so it's meant to be re-downloaded per-machine. Developers new to the project re-run the install command once after cloning. From c9fb1fd7d6e70877a337af871764f45778b9a0bf Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 20 Apr 2026 13:20:57 -0700 Subject: [PATCH 10/15] Scripts: update-version.sh now also rewrites Plugins/Shared.swift's safeDIVersion The InstallSafeDITool XcodeCommandPlugin can't read the package manifest at runtime, so it consumes a hardcoded SafeDI version from `Plugins/Shared.swift`. If the publish workflow doesn't keep that in sync, every release ships a plugin that downloads the wrong release asset. Extend `Scripts/update-version.sh` to rewrite that version via a sed range-address (the getter body is multi-line). Also add a corresponding CI verification step so a future refactor that breaks the sed regex is caught before a release. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 2 ++ Scripts/update-version.sh | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b78a51c0..52f2863b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,8 @@ jobs: run: grep -q 'releases/download/99.99.99-test/SafeDITool.artifactbundle.zip' Package.swift - name: Verify checksum was updated run: grep -q 'checksum:.*abc123testchecksum456' Package.swift + - name: Verify Plugins/Shared.swift safeDIVersion was updated + run: grep -q '"99.99.99-test"' Plugins/Shared.swift xcodebuild: name: Build with xcodebuild on Xcode 26 diff --git a/Scripts/update-version.sh b/Scripts/update-version.sh index ea2aac98..eece96b3 100755 --- a/Scripts/update-version.sh +++ b/Scripts/update-version.sh @@ -1,7 +1,9 @@ #!/bin/bash -# Updates the artifact bundle URL and checksum in Package.swift. -# Used by the publish workflow after building the artifact bundle. +# Updates the artifact bundle URL and checksum in Package.swift, and +# the InstallSafeDITool plugin's hardcoded SafeDI version in +# Plugins/Shared.swift. Used by the publish workflow after building the +# artifact bundle. # # Usage: ./Scripts/update-version.sh # Example: ./Scripts/update-version.sh 2.0.0 abc123def456 @@ -25,4 +27,14 @@ sed -i '' "s|https://github.com/dfed/SafeDI/releases/download/[^\"]*|https://git sed -i '' "s|checksum: \"[^\"]*\"|checksum: \"${CHECKSUM}\"|" Package.swift echo " Package.swift: URL and checksum updated" + +# Update the InstallSafeDITool XcodeCommandPlugin's hardcoded SafeDI +# version (Xcode plugins can't read the package manifest at runtime, so +# the version has to live in source). The declaration spans multiple +# lines, so scope the replacement to the `safeDIVersion` getter body +# via an address range — the only quoted string inside that range is +# the version literal. +sed -i '' -E "/var safeDIVersion: String \{/,/\}/ s|\"[^\"]+\"|\"${VERSION}\"|" Plugins/Shared.swift + +echo " Plugins/Shared.swift: safeDIVersion updated" echo "Done." From 429400609a5d3e21c8b7e66f8b66c549fd802114 Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 20 Apr 2026 13:22:03 -0700 Subject: [PATCH 11/15] Install: clarify InstallSafeDITool plugin description as Xcode-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verb's description is what Xcode surfaces in its right-click plugin menu and what `swift package plugin --list` prints. Both audiences need to know this plugin only makes sense for Xcode projects — swift build users already get the prebuilt tool through the default `prebuilt` trait. Co-Authored-By: Claude Opus 4.7 (1M context) --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 7fef37f2..db98e4a5 100644 --- a/Package.swift +++ b/Package.swift @@ -114,7 +114,7 @@ let package = Package( capability: .command( intent: .custom( verb: "safedi-install-tool", - description: "Downloads the SafeDITool prebuilt release binary for the current SafeDI version.", + description: "Xcode-only: downloads the SafeDITool prebuilt release binary for the current SafeDI version into .safedi//safeditool next to the .xcodeproj. swift build users get the prebuilt tool via the default `prebuilt` trait and don't need this.", ), permissions: [ .writeToPackageDirectory(reason: "Downloads the SafeDITool binary into .safedi//safeditool."), From 73ee0d1731f44c9c319d2c25e2d130a1194dbfcd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 20 Apr 2026 20:38:01 +0000 Subject: [PATCH 12/15] Release 2.0.0-alpha-18-xcode-plugin-test --- Package.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index db98e4a5..fe456211 100644 --- a/Package.swift +++ b/Package.swift @@ -134,8 +134,8 @@ let package = Package( ), .binaryTarget( name: "SafeDIToolBinary", - url: "https://github.com/dfed/SafeDI/releases/download/2.0.0-beta-4/SafeDITool.artifactbundle.zip", - checksum: "ded85f7f8f7c72ad552d4fbe239ba68091b1d9af6b4f028423bce516d8189e9f", + url: "https://github.com/dfed/SafeDI/releases/download/2.0.0-alpha-18-xcode-plugin-test/SafeDITool.artifactbundle.zip", + checksum: "69f3e49ba15125521937a1be7b5de2350998d7092350d3b98db782046d2379a9", ), .executableTarget( name: "SafeDITool", From f505bf2d113b7e03d33405492b136074a07d4b7c Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 20 Apr 2026 13:40:58 -0700 Subject: [PATCH 13/15] Publish: also stage Plugins/Shared.swift in the release commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update-version.sh now rewrites both Package.swift AND Plugins/Shared.swift (the hardcoded safeDIVersion consumed by the Xcode command plugin). The publish workflow's `git add Package.swift` was missing the second file, so the release commit silently left the plugin pointing at the previous version — confirmed in the 2.0.0-alpha-18-xcode-plugin-test run: Package.swift got stamped, Plugins/Shared.swift did not. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 70adb65d..2f6b122a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -221,7 +221,7 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add Package.swift + git add Package.swift Plugins/Shared.swift git commit -m "Release ${{ inputs.version }}" - name: Upload release commit patch (dry run) From 06846091290680e3f57a4996d5392add2babff9b Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 20 Apr 2026 14:03:52 -0700 Subject: [PATCH 14/15] Install: address self-review + codex findings (revert test release, error gating, stderr drain) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Revert test-release artifact pin in Package.swift.** The publish workflow commit for the `2.0.0-alpha-18-xcode-plugin-test` release stamped SafeDIToolBinary to point at that alpha asset. Revert to `2.0.0-beta-4` + original checksum so this branch doesn't ship the test artifact pin to main. The publish workflow will re-stamp on the next real release. **Xcode build plugin: distinguish launch from process failures.** The Xcode variant's `catch {}` at the scan site downgraded every `runSafeDITool` error to a PluginScanner fallback. Scan process failures (parse errors, validation errors) were silently masked — the regex scanner would then produce incomplete output, and users would see cryptic downstream build errors. Now only `SafeDIToolLaunchError` falls back (matching the SPM variant's pattern); `SafeDIToolProcessError` bubbles up. **Drain stderr via FileHandle.nullDevice in verifiedDownloadedToolLocation.** The attached `Pipe()` was never read. An unread pipe deadlocks `waitUntilExit()` once the buffer fills (~64 KB), and this helper runs on the plugin-setup thread. Route stderr to /dev/null via `FileHandle.nullDevice` — no read-side coupling, no deadlock. **Tighten the update-version-check CI grep.** The bare `grep '"99.99.99-test"' Plugins/Shared.swift` matched anywhere in the file, so a future test-string literal elsewhere could satisfy the assertion even if the sed pattern broke. Scope via `grep -A 2 'var safeDIVersion: String {'` so only the getter's range counts. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 5 ++++- Package.swift | 4 ++-- .../SafeDIGenerateDependencyTree.swift | 12 +++++++++--- Plugins/Shared.swift | 6 +++++- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52f2863b..c6c99a42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,10 @@ jobs: - name: Verify checksum was updated run: grep -q 'checksum:.*abc123testchecksum456' Package.swift - name: Verify Plugins/Shared.swift safeDIVersion was updated - run: grep -q '"99.99.99-test"' Plugins/Shared.swift + # Match the literal within 3 lines after the `safeDIVersion` + # getter declaration so a future test-string in an unrelated + # spot can't satisfy a bare file-wide grep. + run: grep -A 2 'var safeDIVersion: String {' Plugins/Shared.swift | grep -q '"99.99.99-test"' xcodebuild: name: Build with xcodebuild on Xcode 26 diff --git a/Package.swift b/Package.swift index fe456211..db98e4a5 100644 --- a/Package.swift +++ b/Package.swift @@ -134,8 +134,8 @@ let package = Package( ), .binaryTarget( name: "SafeDIToolBinary", - url: "https://github.com/dfed/SafeDI/releases/download/2.0.0-alpha-18-xcode-plugin-test/SafeDITool.artifactbundle.zip", - checksum: "69f3e49ba15125521937a1be7b5de2350998d7092350d3b98db782046d2379a9", + url: "https://github.com/dfed/SafeDI/releases/download/2.0.0-beta-4/SafeDITool.artifactbundle.zip", + checksum: "ded85f7f8f7c72ad552d4fbe239ba68091b1d9af6b4f028423bce516d8189e9f", ), .executableTarget( name: "SafeDITool", diff --git a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift index 09afc3bf..ad8b8c2b 100644 --- a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift @@ -274,10 +274,16 @@ extension Target { outputFiles: outputFiles, ), ] - } catch { - Diagnostics.warning("SafeDITool scan failed (\(error)). Falling back to in-process scan.") - // fall through to PluginScanner below + } catch let error as SafeDIToolLaunchError { + // Binary couldn't launch (wrong platform, corrupted, etc). + // This is the recoverable case — fall through to the + // in-process `PluginScanner` below. + Diagnostics.warning("SafeDITool could not be launched during plugin setup (\(error)). Falling back to in-process scan.") } + // SafeDIToolProcessError (scan ran but exited non-zero — + // parse errors, validation failures, etc.) intentionally + // bubbles up. Falling back to a regex-based scanner would + // mask the real error and produce silently-wrong output. } let scanResult = PluginScanner.scan( diff --git a/Plugins/Shared.swift b/Plugins/Shared.swift index cd7105c5..c28387ed 100644 --- a/Plugins/Shared.swift +++ b/Plugins/Shared.swift @@ -87,7 +87,11 @@ import PackagePlugin process.arguments = ["--version"] let outPipe = Pipe() process.standardOutput = outPipe - process.standardError = Pipe() + // Discard stderr instead of piping it — an unread pipe deadlocks + // `waitUntilExit()` once its buffer fills (~64 KB), and this + // helper blocks the plugin-setup thread. `FileHandle.nullDevice` + // routes stderr to /dev/null without any read-side coupling. + process.standardError = FileHandle.nullDevice do { try process.run() } catch { From 56afc61a369bd7b05a93db28ebcbef9d19282c5f Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 20 Apr 2026 15:33:46 -0700 Subject: [PATCH 15/15] CI: wrap update-version-check's grep in block scalar to fix YAML syntax The single-quoted `'var safeDIVersion: String {'` in the `run:` value contained a `{` that YAML parsed as a flow-mapping start. CI failed before any job could execute: "Invalid workflow file... line 35." A block scalar (`run: |`) scopes the whole command as a literal and sidesteps the ambiguity. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6c99a42..63915311 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,8 +31,10 @@ jobs: - name: Verify Plugins/Shared.swift safeDIVersion was updated # Match the literal within 3 lines after the `safeDIVersion` # getter declaration so a future test-string in an unrelated - # spot can't satisfy a bare file-wide grep. - run: grep -A 2 'var safeDIVersion: String {' Plugins/Shared.swift | grep -q '"99.99.99-test"' + # spot can't satisfy a bare file-wide grep. Block scalar because + # an unquoted inline `{` trips the YAML parser. + run: | + grep -A 2 'var safeDIVersion: String {' Plugins/Shared.swift | grep -q '"99.99.99-test"' xcodebuild: name: Build with xcodebuild on Xcode 26