diff --git a/Sources/swifterpm/FileSystemSupport.swift b/Sources/swifterpm/FileSystemSupport.swift index f1f83ae..ace5431 100644 --- a/Sources/swifterpm/FileSystemSupport.swift +++ b/Sources/swifterpm/FileSystemSupport.swift @@ -26,6 +26,63 @@ extension AbsolutePath { } } +extension URL { + /// Path of `self` expressed relative to `base`, using `..` components as needed. + /// Returns `"."` when `self` and `base` refer to the same path. + /// Used to keep `.build/workspace-state.json` and the swifterpm package-info index + /// relocatable: paths are anchored to `scratchDir` so a cached `.build/` works + /// after the project tree moves to a different absolute location. + func relativePathString(to base: URL) throws -> String { + let relative = try absolutePath.relative(to: base.absolutePath).pathString + return relative.isEmpty ? "." : relative + } + + /// Returns `self` encoded relative to `scratchDir` only when it lives inside + /// `packageDir`; otherwise returns the absolute path verbatim. + /// + /// This scopes the relative-path encoding to paths that are part of the consuming + /// project (its scratch dir, in-tree binaries, and in-tree fileSystem deps). Paths + /// that point outside the project tree (e.g. an external local fileSystem or + /// `localSourceControl` dep on another disk location) are host-specific by nature + /// and keep their absolute form, matching SwiftPM's `workspace-state.json` so the + /// e2e differential suite still holds for those entries. + func pathRelativeToScratchIfInsidePackage( + scratchDir: URL, packageDir: URL + ) throws -> String { + // Relativize when this URL lives under either the project root (packageDir) or + // the scratch dir. Both are project-scoped, so paths inside either are part of + // the cacheable `.build/`-rooted unit. Paths outside both (typically external + // local fileSystem / localSourceControl deps) stay absolute so SwiftPM-compat + // against the e2e differential suite still holds for those entries. + // + // We normalize the macOS `/private/var` vs `/var` symlink layer with a string + // substitution rather than POSIX `realpath`. realpath follows every symlink, + // and on developer machines `/swifterpm/artifacts//` is a + // symlink into the global cache; following it would make a path that's + // logically inside scratch look like it lives outside. + let selfNormalized = Self.normalizedPathString(path) + let packageNormalized = Self.normalizedPathString(packageDir.path) + let scratchNormalized = Self.normalizedPathString(scratchDir.path) + + let selfAbs = try AbsolutePath(validating: selfNormalized) + let packageAbs = try AbsolutePath(validating: packageNormalized) + let scratchAbs = try AbsolutePath(validating: scratchNormalized) + + let packageRelative = packageAbs == selfAbs ? "" : try selfAbs.relative(to: packageAbs).pathString + let scratchRelative = scratchAbs == selfAbs ? "" : try selfAbs.relative(to: scratchAbs).pathString + let insidePackage = !packageRelative.hasPrefix("..") + let insideScratch = !scratchRelative.hasPrefix("..") + guard insidePackage || insideScratch else { + return path + } + return scratchRelative.isEmpty ? "." : scratchRelative + } + + private static func normalizedPathString(_ value: String) -> String { + value.replacingOccurrences(of: "/private/var/", with: "/var/") + } +} + extension FileSystem { /// Write `data` atomically by writing to a temp sibling and then replacing the destination. /// Creates parent directories if missing. diff --git a/Sources/swifterpm/PackageInfoCache.swift b/Sources/swifterpm/PackageInfoCache.swift index 88f1028..ede06ed 100644 --- a/Sources/swifterpm/PackageInfoCache.swift +++ b/Sources/swifterpm/PackageInfoCache.swift @@ -76,8 +76,13 @@ enum PackageInfoCacheWriter { _ = try await cachedOrDumpPackageJSON( packageDir: packagePath, destination: packageInfoPath, disableSandbox: disableSandbox) - return packageEntry( - pin: pin, packagePath: packagePath, packageInfoPath: packageInfoPath) + return try packageEntry( + pin: pin, + packagePath: packagePath, + packageInfoPath: packageInfoPath, + packageDir: packageDir, + scratchDir: scratchDir + ) }.sorted { $0.identity < $1.identity } var allPackages = packages @@ -101,14 +106,19 @@ enum PackageInfoCacheWriter { _ = try await cachedOrDumpPackageJSON( packageDir: packagePath, destination: packageInfoPath, disableSandbox: disableSandbox) + let scopedPackagePath = try packagePath.pathRelativeToScratchIfInsidePackage( + scratchDir: scratchDir, packageDir: packageDir + ) let entry = PackageInfoEntry( identity: dependency.identity, kind: "fileSystem", - location: packagePath.path, + location: scopedPackagePath, version: nil, revision: nil, - packagePath: packagePath.path, - packageInfoPath: packageInfoPath.path + packagePath: scopedPackagePath, + packageInfoPath: try packageInfoPath.pathRelativeToScratchIfInsidePackage( + scratchDir: scratchDir, packageDir: packageDir + ) ) return entry } @@ -119,17 +129,22 @@ enum PackageInfoCacheWriter { } allPackages.append(contentsOf: localPackages) + let scopedPackageDir = try packageDir.pathRelativeToScratchIfInsidePackage( + scratchDir: scratchDir, packageDir: packageDir + ) let index = PackageInfoIndex( - schemaVersion: 1, + schemaVersion: 2, generatedAtUnix: UInt64(Date().timeIntervalSince1970), root: PackageInfoEntry( identity: "root", kind: "root", - location: packageDir.path, + location: scopedPackageDir, version: nil, revision: resolved.originHash, - packagePath: packageDir.path, - packageInfoPath: rootPath.path + packagePath: scopedPackageDir, + packageInfoPath: try rootPath.pathRelativeToScratchIfInsidePackage( + scratchDir: scratchDir, packageDir: packageDir + ) ), packages: allPackages ) @@ -182,17 +197,38 @@ enum PackageInfoCacheWriter { return cacheDate >= manifestDate } - private static func packageEntry(pin: ResolvedPin, packagePath: URL, packageInfoPath: URL) - -> PackageInfoEntry - { - PackageInfoEntry( + private static func packageEntry( + pin: ResolvedPin, + packagePath: URL, + packageInfoPath: URL, + packageDir: URL, + scratchDir: URL + ) throws -> PackageInfoEntry { + // localSourceControl pins carry a filesystem path in `location`. Relativize only + // when that path lives inside the consuming project; external clones stay + // absolute so SwiftPM-compat against the e2e diff fixtures still holds. Remote + // and registry pins keep their URL or identity verbatim. + let location: String + if pin.kind == "localSourceControl" { + location = try URL(fileURLWithPath: pin.location) + .pathRelativeToScratchIfInsidePackage( + scratchDir: scratchDir, packageDir: packageDir + ) + } else { + location = pin.location + } + return PackageInfoEntry( identity: pin.identity, kind: pin.kind, - location: pin.location, + location: location, version: pin.state.version, revision: pin.state.revision, - packagePath: packagePath.path, - packageInfoPath: packageInfoPath.path + packagePath: try packagePath.pathRelativeToScratchIfInsidePackage( + scratchDir: scratchDir, packageDir: packageDir + ), + packageInfoPath: try packageInfoPath.pathRelativeToScratchIfInsidePackage( + scratchDir: scratchDir, packageDir: packageDir + ) ) } diff --git a/Sources/swifterpm/Restore.swift b/Sources/swifterpm/Restore.swift index b4410b4..0f60c91 100644 --- a/Sources/swifterpm/Restore.swift +++ b/Sources/swifterpm/Restore.swift @@ -353,7 +353,7 @@ enum WorkspaceRestorer { if let packageDir { contexts.append( PackageContext( - packageRef: rootPackageRef(packageDir), + packageRef: try rootPackageRef(packageDir, scratchDir: scratchDir), packagePath: packageDir, canonicalizeLocalBinaryPaths: true )) @@ -368,9 +368,11 @@ enum WorkspaceRestorer { ) { contexts.append( PackageContext( - packageRef: fileSystemPackageRef( + packageRef: try fileSystemPackageRef( localPackage.dependency, packagePath: localPackage.packagePath, + packageDir: packageDir, + scratchDir: scratchDir, name: ManifestParser.packageName(localPackage.manifest) ), packagePath: localPackage.packagePath, @@ -389,6 +391,8 @@ enum WorkspaceRestorer { packageRef: try await packageRef( pin, packagePath: packagePath, + packageDir: packageDir, + scratchDir: scratchDir, disableSandbox: disableSandbox ), packagePath: packagePath, @@ -399,12 +403,14 @@ enum WorkspaceRestorer { return contexts } - private static func rootPackageRef(_ packageDir: URL) -> [String: String] { + private static func rootPackageRef(_ packageDir: URL, scratchDir: URL) throws -> [String: String] { let canonical = PathCanonicalizer.realpath(packageDir) return [ "identity": canonical.lastPathComponent.lowercased(), "kind": "root", - "location": canonical.path, + "location": try canonical.pathRelativeToScratchIfInsidePackage( + scratchDir: scratchDir, packageDir: canonical + ), "name": canonical.lastPathComponent, ] } @@ -412,19 +418,25 @@ enum WorkspaceRestorer { private static func fileSystemPackageRef( _ dependency: ManifestFileSystemDependency, packagePath: URL, + packageDir: URL, + scratchDir: URL, name: String? = nil - ) - -> [String: String] - { + ) throws -> [String: String] { [ "identity": dependency.identity, "kind": "fileSystem", - "location": packagePath.path, + "location": try packagePath.pathRelativeToScratchIfInsidePackage( + scratchDir: scratchDir, packageDir: packageDir + ), "name": name ?? dependency.name, ] } - private static func packageRef(_ pin: ResolvedPin) throws -> [String: String] { + private static func packageRef( + _ pin: ResolvedPin, + packageDir: URL?, + scratchDir: URL + ) throws -> [String: String] { if PinKind.isRegistry(pin.kind) { return [ "identity": pin.identity, @@ -433,10 +445,22 @@ enum WorkspaceRestorer { "name": pin.identity, ] } + // localSourceControl pins carry a filesystem path in `location`. Relativize when + // that path lives inside the consuming project; otherwise leave it absolute so + // SwiftPM-compat against external local-control deps holds. + let location: String + if pin.kind == "localSourceControl", let packageDir { + location = try URL(fileURLWithPath: pin.location) + .pathRelativeToScratchIfInsidePackage( + scratchDir: scratchDir, packageDir: packageDir + ) + } else { + location = pin.location + } return [ "identity": pin.identity, "kind": pin.kind, - "location": pin.location, + "location": location, "name": PinKind.checkoutDirectoryName(pin), ] } @@ -444,9 +468,11 @@ enum WorkspaceRestorer { private static func packageRef( _ pin: ResolvedPin, packagePath: URL, + packageDir: URL?, + scratchDir: URL, disableSandbox: Bool ) async throws -> [String: String] { - var ref = try packageRef(pin) + var ref = try packageRef(pin, packageDir: packageDir, scratchDir: scratchDir) guard PinKind.isSourceControl(pin.kind) else { return ref } @@ -827,6 +853,8 @@ enum WorkspaceRestorer { let ref = try await packageRef( pin, packagePath: packagePathForPin(scratchDir: scratchDir, pin: pin), + packageDir: packageDir, + scratchDir: scratchDir, disableSandbox: disableSandbox ) dependencies.append([ @@ -839,7 +867,7 @@ enum WorkspaceRestorer { "subpath": PinKind.checkoutDirectoryName(pin), ]) } else if PinKind.isRegistry(pin.kind) { - let ref = try packageRef(pin) + let ref = try packageRef(pin, packageDir: packageDir, scratchDir: scratchDir) try dependencies.append([ "basedOn": NSNull(), "packageRef": ref, @@ -860,9 +888,11 @@ enum WorkspaceRestorer { rootManifest: manifest, disableSandbox: disableSandbox ) { - let ref = fileSystemPackageRef( + let ref = try fileSystemPackageRef( localPackage.dependency, packagePath: localPackage.packagePath, + packageDir: packageDir, + scratchDir: scratchDir, name: ManifestParser.packageName(localPackage.manifest) ) dependencies.append([ @@ -870,7 +900,9 @@ enum WorkspaceRestorer { "packageRef": ref, "state": [ "name": "fileSystem", - "path": localPackage.packagePath.path, + "path": try localPackage.packagePath.pathRelativeToScratchIfInsidePackage( + scratchDir: scratchDir, packageDir: packageDir + ), ], "subpath": localPackage.dependency.identity, ]) @@ -961,6 +993,7 @@ enum WorkspaceRestorer { try await workspaceArtifact( target, context: context, + packageDir: packageDir, scratchDir: scratchDir ) } @@ -972,6 +1005,7 @@ enum WorkspaceRestorer { private static func workspaceArtifact( _ target: ManifestBinaryTarget, context: PackageContext, + packageDir: URL, scratchDir: URL ) async throws -> WorkspaceArtifact? { let identity = context.packageRef["identity"] ?? target.name @@ -1027,7 +1061,9 @@ enum WorkspaceRestorer { value: [ "kind": artifact.kind, "packageRef": context.packageRef, - "path": artifact.path.path, + "path": try artifact.path.pathRelativeToScratchIfInsidePackage( + scratchDir: scratchDir, packageDir: packageDir + ), "source": source, "targetName": target.name, ]) diff --git a/Tests/swifterpmTests/PackageInfoCacheTests.swift b/Tests/swifterpmTests/PackageInfoCacheTests.swift index b755179..38d42d7 100644 --- a/Tests/swifterpmTests/PackageInfoCacheTests.swift +++ b/Tests/swifterpmTests/PackageInfoCacheTests.swift @@ -29,12 +29,15 @@ struct PackageInfoCacheTests { JSONSerialization.jsonObject( with: try await fileSystem.readFile(at: indexPath.absolutePath)) as? [String: Any]) - #expect(index["schema_version"] as? Int == 1) + #expect(index["schema_version"] as? Int == 2) #expect((index["packages"] as? [[String: Any]])?.isEmpty == true) let rootEntry = try #require(index["root"] as? [String: Any]) #expect(rootEntry["identity"] as? String == "root") #expect(rootEntry["revision"] as? String == "origin") + #expect( + rootEntry["package_path"] as? String + == (try package.relativePathString(to: scratch))) } } @@ -193,12 +196,20 @@ struct PackageInfoCacheTests { packages.compactMap { $0["identity"] as? String } == [ "local-one", "local-two", ]) - #expect( - packages.first?["package_path"] as? String - == PathCanonicalizer.realpath(localOne).path) + // LocalOne is inside packageDir; encoded relative to scratch via packageDir. + // LocalTwo lives outside packageDir; stays absolute. + #expect(packages.first?["package_path"] as? String == "../Package/LocalOne") for package in packages { - let packageInfoPath = try #require(package["package_info_path"] as? String) - #expect(try await fileSystem.exists(URL(fileURLWithPath: packageInfoPath).absolutePath)) + let pathString = try #require(package["package_info_path"] as? String) + // cacheDir is a sibling of scratch and outside packageDir in this test + // setup, so package_info_path stays absolute. Real Tuist projects place + // cacheDir at /swifterpm/package-info which keeps it relative. + let resolvedURL: URL = if pathString.hasPrefix("/") { + URL(fileURLWithPath: pathString) + } else { + scratch.appendingPathComponent(pathString).standardizedFileURL + } + #expect(try await fileSystem.exists(resolvedURL.absolutePath)) } } } diff --git a/Tests/swifterpmTests/RestoreTests.swift b/Tests/swifterpmTests/RestoreTests.swift index 9c49513..d174a21 100644 --- a/Tests/swifterpmTests/RestoreTests.swift +++ b/Tests/swifterpmTests/RestoreTests.swift @@ -238,13 +238,12 @@ struct RestoreTests { ) #expect(Set(refsByIdentity.keys) == ["local-one", "local-two"]) - let expectedLocalOne = PathCanonicalizer.realpath(localOne).path - let expectedLocalTwo = PathCanonicalizer.realpath(localTwo).path - #expect(refsByIdentity["local-one"]?["location"] as? String == expectedLocalOne) - #expect(refsByIdentity["local-two"]?["location"] as? String == expectedLocalTwo) + // The deps live under packageDir, so they're encoded relative to scratch. + #expect(refsByIdentity["local-one"]?["location"] as? String == "../Package/LocalOne") + #expect(refsByIdentity["local-two"]?["location"] as? String == "../Package/LocalTwo") #expect( Set(dependencies.compactMap { ($0["state"] as? [String: Any])?["path"] as? String }) - == [expectedLocalOne, expectedLocalTwo]) + == ["../Package/LocalOne", "../Package/LocalTwo"]) } } @@ -282,7 +281,9 @@ struct RestoreTests { let source = try #require(artifact["source"] as? [String: Any]) #expect(artifacts.count == 1) #expect(artifact["targetName"] as? String == "Foo") - #expect(artifact["path"] as? String == PathCanonicalizer.realpath(framework).path) + // framework is inside packageDir, scratch is a sibling, so the artifact is + // emitted relative to scratch via packageDir. + #expect(artifact["path"] as? String == "../Package/XCFrameworks/Foo.xcframework") #expect(packageRef["kind"] as? String == "root") #expect(packageRef["identity"] as? String == "package") #expect(source["type"] as? String == "local") @@ -350,7 +351,9 @@ struct RestoreTests { #expect(artifacts.count == 1) #expect(artifact["targetName"] as? String == "Foo") - #expect(artifact["path"] as? String == artifactPath.path) + #expect( + artifact["path"] as? String + == "swifterpm/artifacts/binary/Foo/Foo.xcframework") #expect(packageRef["kind"] as? String == "remoteSourceControl") #expect(packageRef["location"] as? String == "https://github.com/example/binary.git") #expect(source["type"] as? String == "remote") @@ -462,6 +465,70 @@ struct RestoreTests { } } + @Test + func writeWorkspaceStateEmitsScratchRelativeArtifactAndDependencyPaths() async throws { + // Regression test for cross-host `.build/` caching: every path written to + // workspace-state.json should be relative to the scratch directory, so a + // cached `.build/` can be restored under a different absolute prefix and + // still resolve back to real files. + try await withTemporaryDirectory { root in + let package = root.appendingPathComponent("Package") + let local = package.appendingPathComponent("LocalDep") + let scratch = root.appendingPathComponent("Package/.build") + let framework = package.appendingPathComponent("XCFrameworks/Foo.xcframework") + try await fileSystem.makeDirectory(at: framework.absolutePath, options: [.createTargetParentDirectories]) + try await fileSystem.atomicWrite( + validXCFrameworkInfoPlist(), + to: framework.appendingPathComponent("Info.plist") + ) + var rootManifest = localBinaryTargetManifest(name: "Foo", path: "XCFrameworks/Foo.xcframework") + rootManifest["dependencies"] = [ + [ + "fileSystem": [ + [ + "identity": "local-dep", + "path": "LocalDep", + ], + ] + ], + ] + try await writeCachedManifest(rootManifest, packageDir: package) + try await writeCachedManifest(emptyManifest(name: "LocalDep"), packageDir: local) + + try await WorkspaceRestorer.writeWorkspaceState( + packageDir: package, + scratchDir: scratch, + resolved: ResolvedPins(originHash: "origin", pins: [], version: 3), + disableSandbox: false + ) + + let statePath = scratch.appendingPathComponent("workspace-state.json") + let state = try #require( + try JSONSerialization.jsonObject( + with: await fileSystem.readFile(at: statePath.absolutePath)) + as? [String: Any]) + let object = try #require(state["object"] as? [String: Any]) + let artifacts = try #require(object["artifacts"] as? [[String: Any]]) + let dependencies = try #require(object["dependencies"] as? [[String: Any]]) + + let artifactPath = try #require(artifacts.first?["path"] as? String) + let dependencyStatePath = try #require( + (dependencies.first?["state"] as? [String: Any])?["path"] as? String) + let dependencyLocation = try #require( + (dependencies.first?["packageRef"] as? [String: Any])?["location"] as? String) + + #expect(!artifactPath.hasPrefix("/")) + #expect(!dependencyStatePath.hasPrefix("/")) + #expect(!dependencyLocation.hasPrefix("/")) + + // Sanity check: anchoring back to scratch resolves to the real on-disk file. + let resolvedArtifact = scratch + .appendingPathComponent(artifactPath) + .standardizedFileURL + #expect(try await fileSystem.exists(resolvedArtifact.absolutePath)) + } + } + private func makeXCFrameworkZip(root: URL, targetName: String) async throws -> URL { let archiveRoot = root.appendingPathComponent("archive") let framework = archiveRoot.appendingPathComponent("\(targetName).xcframework")