Repository: apple/swift-docc (swift)

Issue:
Permission issue when Swift toolchain/docc is installed as root user
### Description

When the Swift toolchain is installed to `/` or `/usr/libexec` (as is done with Debian and Fedora distributions), the `docc` binary throws a permissions issue when attempting to preview or generate documentation:

```bash
xtremek@Winter ~/t/swift-async-algorithms (main) [1]> ls -la /usr/libexec/swift/6.0.3/bin/docc
-rwxr-xr-x 1 root root 23984936 Dec 15 19:00 /usr/libexec/swift/6.0.3/bin/docc*
xtremek@Winter ~/t/swift-async-algorithms (main)> /usr/libexec/swift/6.0.3/bin/docc preview
Input: ~/tmp/swift-async-algorithms
Template: /usr/libexec/swift/6.0.3/share/docc/render
Error: Error Domain=NSCocoaErrorDomain Code=513 "You don’t have permission."
```

However, if the Swift toolchain and `docc` are installed to a directory under the user, like in the user's home directory or via Swiftly, then it works perfectly fine:

```
xtremek@Winter ~/t/swift-async-algorithms (main)> ls -la $(which docc)
lrwxrwxrwx 1 xtremek xtremek 32 Jan  4 15:36 ~/.local/bin/docc -> ~/.local/bin/swiftly*
xtremek@Winter ~/t/swift-async-algorithms (main)> ~/.local/bin/docc preview
Input: ~/tmp/swift-async-algorithms
Template: ~/.local/share/swiftly/toolchains/6.0.3/usr/share/docc/render
========================================
Starting Local Preview Server
         Address: http://localhost:8080/documentation/validation
========================================
```

Any ideas why this happens? It makes it impossible to have Swift installed to the system and then use docc to preview and generate documentation, for example in a CI environment...

### Checklist

- [X] If possible, I've reproduced the issue using the `main` branch of this package.
- [X] This issue hasn't been addressed in an [existing GitHub issue](https://github.com/apple/swift-docc/issues).

### Expected Behavior

Running `docc convert` or `docc preview` when the Swift toolchain is installed to a system directory with `root` permissions works.

### Actual behavior

Running `docc convert` or `docc preview` when the Swift toolchain is installed to a system directory with `root` permissions throws a permissions error:

`Error: Error Domain=NSCocoaErrorDomain Code=513 "You don’t have permission."`

### Steps To Reproduce

1. Install Swift to a system directory such as / or /usr/libexec/swift.
2. Run `docc preview` from the system directory.
3. Observe that it does not work, but throws a permissions issue.

### Swift-DocC Version Information

Swift 6.0.3 Release

### Swift Compiler Version Information

```shell
Swift version 6.0.3 (swift-6.0.3-RELEASE)
Target: aarch64-unknown-linux-gnu
```

Pull request description:
Bug/issue #, if applicable: rdar://146539299 https://github.com/swiftlang/swift-docc/issues/1136

## Summary

This works around https://github.com/swiftlang/swift-foundation/issues/1125 for all the copy item operations that DocC does to avoid potential permission issues when copying file attributes over to the otherwise successfully copied items.

## Dependencies

None.

## Testing

Rephrased from https://github.com/swiftlang/swift-docc/issues/1136:

- Install Swift to a system directory such as `/` or `/usr/libexec/swift`.
- Run `docc convert` from the system directory.

The `convert` action should succeed, without permission error or other unexpected file write errors. 

## Checklist

Make sure you check off the following items. If they cannot be completed, provide a reason.

- ~[ ] Added tests~
- [x] Ran the `./bin/test` script and it succeeded
- ~[ ] Updated documentation if necessary~ Added in-source comments to explain the workaround.

Patch:
diff --git a/Sources/SwiftDocC/Utility/FileManagerProtocol.swift b/Sources/SwiftDocC/Utility/FileManagerProtocol.swift
index 4331c99c5a..54407bf4f4 100644
--- a/Sources/SwiftDocC/Utility/FileManagerProtocol.swift
+++ b/Sources/SwiftDocC/Utility/FileManagerProtocol.swift
@@ -39,7 +39,7 @@ package protocol FileManagerProtocol: DataProvider {
     /// Returns `true` if a file exists at the given path.
     func fileExists(atPath: String) -> Bool
     /// Copies a file from one location on the file-system to another.
-    func copyItem(at: URL, to: URL) throws
+    func _copyItem(at: URL, to: URL) throws // Use a different name than FileManager to work around https://github.com/swiftlang/swift-foundation/issues/1125
     /// Moves a file from one location on the file-system to another.
     func moveItem(at: URL, to: URL) throws
     /// Creates a new file folder at the given location.
@@ -144,4 +144,25 @@ extension FileManager: FileManagerProtocol {
             directories: Array( allContents[partitionIndex...] )
         )
     }
+    
+    package func _copyItem(at source: URL, to destination: URL) throws {
+        // Call `NSFileManager/copyItem(at:to:)` and catch the error to workaround https://github.com/swiftlang/swift-foundation/issues/1125
+        do {
+            try copyItem(at: source, to: destination)
+        } catch let error as CocoaError {
+            // In Swift 6 on Linux, `FileManager/copyItems(at:to:)` raises an error _after_ successfully copying the files when it's moving over file attributes from the source to the destination.
+            // To workaround this issue, we check if the destination exist and the error wasn't that the destination _already_ existed.
+            if error.code != CocoaError.Code.fileWriteFileExists,
+               fileExists(atPath: destination.path)
+            {
+                // Ignore this error.
+                // The consequence is that the copied item may have some different attributes (creation date, owner, etc.) compared to the source.
+                // These attributes aren't critical for copying input files over to the output documentation archive.
+                return
+            }
+            
+            // Otherwise, if this was any other error or if the destination file doesn't exist after calling `FileManager/copyItems(at:to:)`, re-throw the error to the caller.
+            throw error
+        }
+    }
 }
diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Action+MoveOutput.swift b/Sources/SwiftDocCUtilities/Action/Actions/Action+MoveOutput.swift
index 3cd7a50e4b..a4a666bf61 100644
--- a/Sources/SwiftDocCUtilities/Action/Actions/Action+MoveOutput.swift
+++ b/Sources/SwiftDocCUtilities/Action/Actions/Action+MoveOutput.swift
@@ -26,7 +26,7 @@ extension AsyncAction {
             // If a template directory has been provided, create the temporary build folder with its contents
             // Ensure that the container exists
             try? fileManager.createDirectory(at: targetURL.deletingLastPathComponent(), withIntermediateDirectories: false, attributes: nil)
-            try fileManager.copyItem(at: template, to: targetURL)
+            try fileManager._copyItem(at: template, to: targetURL)
         } else {
             // Otherwise, create an empty directory
             try fileManager.createDirectory(at: targetURL, withIntermediateDirectories: true, attributes: nil)
diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift
index 982170b324..b4ec765b7e 100644
--- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift
+++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertFileWritingConsumer.swift
@@ -71,7 +71,7 @@ struct ConvertFileWritingConsumer: ConvertOutputConsumer {
         func copyAsset(_ asset: DataAsset, to destinationFolder: URL) throws {
             for sourceURL in asset.variants.values where !sourceURL.isAbsoluteWebURL {
                 let assetName = sourceURL.lastPathComponent
-                try fileManager.copyItem(
+                try fileManager._copyItem(
                     at: sourceURL,
                     to: destinationFolder.appendingPathComponent(assetName, isDirectory: false)
                 )
@@ -143,7 +143,7 @@ struct ConvertFileWritingConsumer: ConvertOutputConsumer {
             if fileManager.fileExists(atPath: targetFile.path) {
                 try fileManager.removeItem(at: targetFile)
             }
-            try fileManager.copyItem(at: themeSettings, to: targetFile)
+            try fileManager._copyItem(at: themeSettings, to: targetFile)
         }
     }
     
diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift
index f264557a12..aa8a67dfbf 100644
--- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift
+++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/JSONEncodingRenderNodeWriter.swift
@@ -104,7 +104,7 @@ class JSONEncodingRenderNodeWriter {
         )
         
         do {
-            try fileManager.copyItem(at: indexHTML, to: htmlTargetFileURL)
+            try fileManager._copyItem(at: indexHTML, to: htmlTargetFileURL)
         } catch let error as NSError where error.code == NSFileWriteFileExistsError {
             // We already have an 'index.html' file at this path. This could be because
             // we're writing to an output directory that already contains built documentation
@@ -112,7 +112,7 @@ class JSONEncodingRenderNodeWriter {
             // have the same path on the filesystem. Either way, we don't want this to error out
             // so just remove the destination item and try the copy operation again.
             try fileManager.removeItem(at: htmlTargetFileURL)
-            try fileManager.copyItem(at: indexHTML, to: htmlTargetFileURL)
+            try fileManager._copyItem(at: indexHTML, to: htmlTargetFileURL)
         }
     }
 }
diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Merge/MergeAction.swift b/Sources/SwiftDocCUtilities/Action/Actions/Merge/MergeAction.swift
index 4e0e65ab10..7ab892d8ab 100644
--- a/Sources/SwiftDocCUtilities/Action/Actions/Merge/MergeAction.swift
+++ b/Sources/SwiftDocCUtilities/Action/Actions/Merge/MergeAction.swift
@@ -70,7 +70,7 @@ struct MergeAction: AsyncAction {
                 try? fileManager.createDirectory(at: toDirectory, withIntermediateDirectories: false, attributes: nil)
                 for from in (try? fileManager.contentsOfDirectory(at: fromDirectory, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)) ?? [] {
                     // Copy each file or subdirectory
-                    try fileManager.copyItem(at: from, to: toDirectory.appendingPathComponent(from.lastPathComponent))
+                    try fileManager._copyItem(at: from, to: toDirectory.appendingPathComponent(from.lastPathComponent))
                 }
             }
             guard let jsonIndexData = fileManager.contents(atPath: archive.appendingPathComponent("index/index.json").path) else {
@@ -125,7 +125,7 @@ struct MergeAction: AsyncAction {
             contents: RenderJSONEncoder.makeEncoder().encode(renderNode)
         )
         // It's expected that this will fail if combined archive doesn't support static hosting.
-        try? fileManager.copyItem(
+        try? fileManager._copyItem(
             at: targetURL.appendingPathComponent("index.html"),
             to: targetURL.appendingPathComponent("/documentation/index.html")
         )
diff --git a/Sources/SwiftDocCUtilities/Action/Actions/TransformForStaticHostingAction.swift b/Sources/SwiftDocCUtilities/Action/Actions/TransformForStaticHostingAction.swift
index cb68f0cb56..3e7dceb140 100644
--- a/Sources/SwiftDocCUtilities/Action/Actions/TransformForStaticHostingAction.swift
+++ b/Sources/SwiftDocCUtilities/Action/Actions/TransformForStaticHostingAction.swift
@@ -61,7 +61,7 @@ struct TransformForStaticHostingAction: AsyncAction {
             // as we want to preserve anything intentionally left in the output URL by `setupOutputDirectory`
             for sourceItem in try fileManager.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: [], options:[.skipsHiddenFiles]) {
                 let targetItem = outputURL.appendingPathComponent(sourceItem.lastPathComponent)
-                try fileManager.copyItem(at: sourceItem, to: targetItem)
+                try fileManager._copyItem(at: sourceItem, to: targetItem)
             }
         }
 
@@ -81,7 +81,7 @@ struct TransformForStaticHostingAction: AsyncAction {
             if fileManager.fileExists(atPath: target.path){
                 try fileManager.removeItem(at: target)
             }
-            try fileManager.copyItem(at: source, to: target)
+            try fileManager._copyItem(at: source, to: target)
         }
         
         // Transform the indexHTML if needed.


Test patch:
diff --git a/Sources/SwiftDocCTestUtilities/TestFileSystem.swift b/Sources/SwiftDocCTestUtilities/TestFileSystem.swift
index a4530f9c00..7c1e253a50 100644
--- a/Sources/SwiftDocCTestUtilities/TestFileSystem.swift
+++ b/Sources/SwiftDocCTestUtilities/TestFileSystem.swift
@@ -160,7 +160,7 @@ package class TestFileSystem: FileManagerProtocol {
         return files.keys.contains(path)
     }
     
-    package func copyItem(at source: URL, to destination: URL) throws {
+    package func _copyItem(at source: URL, to destination: URL) throws {
         guard !disableWriting else { return }
         
         filesLock.lock()
@@ -185,7 +185,7 @@ package class TestFileSystem: FileManagerProtocol {
 
         let srcPath = srcURL.path
 
-        try copyItem(at: srcURL, to: dstURL)
+        try _copyItem(at: srcURL, to: dstURL)
         files.removeValue(forKey: srcPath)
         
         for (path, _) in files where path.hasPrefix(srcPath) {
diff --git a/Tests/SwiftDocCUtilitiesTests/Utility/TestFileSystemTests.swift b/Tests/SwiftDocCUtilitiesTests/Utility/TestFileSystemTests.swift
index 0108e846ef..fd3eb71489 100644
--- a/Tests/SwiftDocCUtilitiesTests/Utility/TestFileSystemTests.swift
+++ b/Tests/SwiftDocCUtilitiesTests/Utility/TestFileSystemTests.swift
@@ -106,7 +106,7 @@ class TestFileSystemTests: XCTestCase {
     func testCopyFiles() throws {
         let fs = try makeTestFS()
         
-        try fs.copyItem(at: URL(string: "/main/nested/myfile1.txt")!, to: URL(string: "/main/myfile1.txt")!)
+        try fs._copyItem(at: URL(string: "/main/nested/myfile1.txt")!, to: URL(string: "/main/myfile1.txt")!)
         XCTAssertEqual(fs.dump(), """
         /
         ├─ main/
@@ -121,7 +121,7 @@ class TestFileSystemTests: XCTestCase {
     func testCopyFolders() throws {
         let fs = try makeTestFS()
         
-        try fs.copyItem(at: URL(string: "/main/nested")!, to: URL(string: "/copy")!)
+        try fs._copyItem(at: URL(string: "/main/nested")!, to: URL(string: "/copy")!)
         XCTAssertEqual(fs.dump(), """
         /
         ├─ copy/
@@ -308,7 +308,7 @@ class TestFileSystemTests: XCTestCase {
         XCTAssertEqual(fs.contents(atPath: "/main/test.txt"), Data(base64Encoded: "TEST"))
         
         // Copy a file and test the contents are identical with original
-        try fs.copyItem(at: URL(string: "/main/test.txt")!, to: URL(string: "/main/clone.txt")!)
+        try fs._copyItem(at: URL(string: "/main/test.txt")!, to: URL(string: "/main/clone.txt")!)
         XCTAssertTrue(fs.contentsEqual(atPath: "/main/test.txt", andPath: "/main/clone.txt"))
         
         _ = try fs.createFile(at: URL(string:"/main/notclone.txt")!, contents: Data(base64Encoded: "TESTTEST")!)