Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ 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
# 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. 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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions Documentation/Manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ 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..."** — installing the prebuilt tool fixes it.

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/<version>/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.

**What to commit:** the install command writes `.safedi/.gitignore` on first run with the single glob `*/safeditool`, which ignores `.safedi/<version>/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

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:
Expand Down
23 changes: 23 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ let package = Package(
name: "MigrateSafeDIFromVersionOne",
targets: ["MigrateSafeDIFromVersionOne"],
),
.plugin(
name: "InstallSafeDITool",
targets: ["InstallSafeDITool"],
),
],
traits: [
.default(enabledTraits: ["prebuilt"]),
Expand Down Expand Up @@ -101,6 +105,25 @@ let package = Package(
dependencies: [],
),

// Downloads the prebuilt SafeDITool release binary into
// `<xcodeProject>/.safedi/<version>/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(
intent: .custom(
verb: "safedi-install-tool",
description: "Xcode-only: downloads the SafeDITool prebuilt release binary for the current SafeDI version into .safedi/<version>/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/<version>/safeditool."),
.allowNetworkConnections(scope: .all(ports: []), reason: "Downloads SafeDITool from the SafeDI GitHub release."),
],
),
dependencies: [],
),

.plugin(
name: "SafeDIGenerator",
capability: .buildTool(),
Expand Down
216 changes: 216 additions & 0 deletions Plugins/InstallSafeDITool/InstallSafeDITool.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
// 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
import PackagePlugin

/// Downloads the prebuilt SafeDITool release binary for the current SafeDI
/// version into `<xcodeProject>/.safedi/<version>/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.
///
/// 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,
arguments _: [String],
) async throws {
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)
}
}

#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. 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 {
defer { dispatchGroup.leave() }
do {
try await downloadTool(
originURL: safeDIOrigin,
version: version,
expectedToolFolder: expectedToolFolder,
expectedToolLocation: expectedToolLocation,
safediFolder: safediFolder,
)
} catch {
Diagnostics.error("\(error)")
exit(1)
}
}
dispatchGroup.wait()
}
}
#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.
///
/// 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,
expectedToolFolder: URL,
expectedToolLocation: URL,
safediFolder: URL,
) async throws {
#if os(macOS)
// GitHub releases publish `SafeDITool-macos-<arch>` 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)
let toolName = "SafeDITool-macos-x86_64"
#else
throw UnsupportedHostError()
#endif

let githubDownloadURL = originURL.appending(
components: "releases",
"download",
version,
toolName,
)
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.
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)) {
// `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)
}

let gitIgnoreLocation = safediFolder.appending(component: ".gitignore")
if !FileManager.default.fileExists(atPath: gitIgnoreLocation.path(percentEncoded: false)) {
// Each version gets its own subfolder (`<version>/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 {
var description: String {
"Unsupported host OS/architecture for SafeDITool download. Supported: macOS on arm64 or x86_64."
}
}

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 {
"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."
}
}
1 change: 1 addition & 0 deletions Plugins/InstallSafeDITool/Shared.swift
86 changes: 79 additions & 7 deletions Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,30 @@ 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/<version>/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 = verifiedDownloadedToolLocation(context.downloadedToolLocation, expectedVersion: context.safeDIVersion) {
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), right-click \
the project in Xcode and choose SafeDI → Safedi Install Tool.
""")
}
let inputSwiftFiles = target
.inputFiles
.filter { $0.url.pathExtension == "swift" }
Expand All @@ -208,12 +231,61 @@ 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 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(
swiftFiles: inputSwiftFiles,
mockScopedSwiftFiles: inputSwiftFiles,
Expand Down
Loading
Loading