Skip to content

Commit 0b631ed

Browse files
committed
Sandbox blocks output to default plugin output directory when it's under <pkgdir>/.build
The sandbox rules introduced in #3996 made that the entire package directory read-only, but that isn't appropriate when `.build` is inside the package directory. This changes the order of the operations so that the `allow` comes after the `deny`, but still after the temporary directories. In order to make this more flexible this coalesces the lists of writable and read-only directories into a single `pathRules` list of a new enum type that allows writing or read-only paths, and allows the caller to control the order. Also, writability of temporary directories is pulled into an option on the `default` strictness. rdar://87417780
1 parent b4cbd56 commit 0b631ed

File tree

7 files changed

+112
-69
lines changed

7 files changed

+112
-69
lines changed

Sources/Basics/Sandbox.swift

Lines changed: 35 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -20,32 +20,34 @@ public enum Sandbox {
2020
///
2121
/// - Parameters:
2222
/// - command: The command line to sandbox (including executable as first argument)
23-
/// - strictness: The basic strictness level of the standbox.
24-
/// - writableDirectories: Paths under which writing should be allowed.
25-
/// - readOnlyDirectories: Paths under which writing should be denied, even if they would have otherwise been allowed by either the strictness level or paths in `writableDirectories`.
23+
/// - strictness: The basic strictness level of the sandbox.
24+
/// - fileSystemRules: Ordered set of rules that allow reading or writing to particular paths in the file system (last one wins for a particular path).
2625
public static func apply(
2726
command: [String],
28-
strictness: Strictness = .default,
29-
writableDirectories: [AbsolutePath] = [],
30-
readOnlyDirectories: [AbsolutePath] = []
27+
strictness: Strictness = .default(writableTemporaryDirectory: false),
28+
pathRules: [PathRule] = []
3129
) -> [String] {
3230
#if os(macOS)
33-
let profile = macOSSandboxProfile(strictness: strictness, writableDirectories: writableDirectories, readOnlyDirectories: readOnlyDirectories)
31+
let profile = macOSSandboxProfile(strictness: strictness, pathRules: pathRules)
3432
return ["/usr/bin/sandbox-exec", "-p", profile] + command
3533
#else
3634
// rdar://40235432, rdar://75636874 tracks implementing sandboxes for other platforms.
3735
return command
3836
#endif
3937
}
4038

39+
/// Represents a readonly/writable rule for a path and everything under it.
40+
public enum PathRule: Equatable {
41+
case readonly(path: AbsolutePath)
42+
case writable(path: AbsolutePath)
43+
}
44+
4145
/// Basic strictness level of a sandbox applied to a command line.
4246
public enum Strictness: Equatable {
43-
/// Blocks network access and all file system modifications.
44-
case `default`
47+
/// Blocks network access and all file system modifications except, optionally, temporary locations.
48+
case `default`(writableTemporaryDirectory: Bool)
4549
/// More lenient restrictions than the default, for compatibility with SwiftPM manifests using a tools version older than 5.3.
4650
case manifest_pre_53 // backwards compatibility for manifests
47-
/// Like `default`, but also makes temporary-files directories (such as `/tmp`) on the platform writable.
48-
case writableTemporaryDirectory
4951
}
5052
}
5153

@@ -54,8 +56,7 @@ public enum Sandbox {
5456
#if os(macOS)
5557
fileprivate func macOSSandboxProfile(
5658
strictness: Sandbox.Strictness,
57-
writableDirectories: [AbsolutePath],
58-
readOnlyDirectories: [AbsolutePath]
59+
pathRules: [Sandbox.PathRule]
5960
) -> String {
6061
var contents = "(version 1)\n"
6162

@@ -78,40 +79,34 @@ fileprivate func macOSSandboxProfile(
7879
contents += "(allow sysctl*)\n"
7980
}
8081

81-
// Allow writing only to certain directories.
82-
var writableDirectoriesExpression = writableDirectories.map {
83-
"(subpath \(resolveSymlinks($0).quotedAsSubpathForSandboxProfile))"
84-
}
8582
// The following accesses are only needed when interpreting the manifest (versus running a compiled version).
86-
if strictness == .manifest_pre_53 {
87-
writableDirectoriesExpression += Platform.threadSafeDarwinCacheDirectories.get().map {
88-
##"(regex #"^\##($0.pathString)/org\.llvm\.clang.*")"##
89-
}
90-
}
91-
// Optionally allow writing to temporary directories (a lot of use of Foundation requires this).
92-
else if strictness == .writableTemporaryDirectory {
93-
// Add `subpath` expressions for the regular and the Foundation temporary directories.
94-
for tmpDir in ["/tmp", NSTemporaryDirectory()] {
95-
writableDirectoriesExpression += ["(subpath \(resolveSymlinks(AbsolutePath(tmpDir)).quotedAsSubpathForSandboxProfile))"]
96-
}
97-
}
98-
99-
// Emit rules for paths under which writing is allowed. Some of these expressions may be regular expressions and others literal subpaths.
100-
if writableDirectoriesExpression.count > 0 {
83+
switch strictness {
84+
case .manifest_pre_53:
85+
// SwiftPM 5.3 and older allowed writing to some other cache directories.
10186
contents += "(allow file-write*\n"
102-
for expression in writableDirectoriesExpression {
103-
contents += " \(expression)\n"
87+
for path in Platform.threadSafeDarwinCacheDirectories.get() {
88+
contents += " (regex #\"^\(resolveSymlinks(path).pathString)/org\\.llvm\\.clang.*\")\n"
10489
}
10590
contents += ")\n"
91+
case .default(let writableTemporaryDirectory):
92+
// Optionally allow writing to the platform-specific temporary directories.
93+
if writableTemporaryDirectory {
94+
contents += "(allow file-write*\n"
95+
for path in ["/tmp", NSTemporaryDirectory()] {
96+
contents += " (subpath \(resolveSymlinks(AbsolutePath(path)).quotedAsSubpathForSandboxProfile))"
97+
}
98+
contents += ")\n"
99+
}
106100
}
107101

108-
// Emit rules for paths under which writing should be disallowed, even if they would be covered by a previous rule to allow writing to them. A classic case is a package which is located under the temporary directory, which should be read-only even though the temporary directory as a whole is writable.
109-
if readOnlyDirectories.count > 0 {
110-
contents += "(deny file-write*\n"
111-
for path in readOnlyDirectories {
112-
contents += " (subpath \(resolveSymlinks(path).quotedAsSubpathForSandboxProfile))\n"
102+
// Emit rules for specific file system locations. Everything is readonly by default, so we just allow or deny writing, in order.
103+
for rule in pathRules {
104+
switch rule {
105+
case .readonly(let path):
106+
contents += "(deny file-write* (subpath \(resolveSymlinks(path).quotedAsSubpathForSandboxProfile)))\n"
107+
case .writable(let path):
108+
contents += "(allow file-write* (subpath \(resolveSymlinks(path).quotedAsSubpathForSandboxProfile)))\n"
113109
}
114-
contents += ")\n"
115110
}
116111

117112
return contents

Sources/Build/BuildOperation.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,10 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS
344344
// TODO: We need to also use any working directory, but that support isn't yet available on all platforms at a lower level.
345345
var commandLine = [command.configuration.executable.pathString] + command.configuration.arguments
346346
if !self.disableSandboxForPluginCommands {
347-
commandLine = Sandbox.apply(command: commandLine, strictness: .writableTemporaryDirectory, writableDirectories: [pluginResult.pluginOutputDirectory])
347+
commandLine = Sandbox.apply(
348+
command: commandLine,
349+
strictness: .default(writableTemporaryDirectory: true),
350+
pathRules: [.writable(path: pluginResult.pluginOutputDirectory)])
348351
}
349352
let processResult = try Process.popen(arguments: commandLine, environment: command.configuration.environment)
350353
let output = try processResult.utf8Output() + processResult.utf8stderrOutput()

Sources/Build/LLBuildManifestBuilder.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -618,7 +618,10 @@ extension LLBuildManifestBuilder {
618618
let displayName = command.configuration.displayName ?? execPath.basename
619619
var commandLine = [execPath.pathString] + command.configuration.arguments
620620
if !self.disableSandboxForPluginCommands {
621-
commandLine = Sandbox.apply(command: commandLine, strictness: .writableTemporaryDirectory, writableDirectories: [result.pluginOutputDirectory])
621+
commandLine = Sandbox.apply(
622+
command: commandLine,
623+
strictness: .default(writableTemporaryDirectory: true),
624+
pathRules: [.writable(path: result.pluginOutputDirectory)])
622625
}
623626
manifest.addShellCmd(
624627
name: displayName + "-" + ByteString(encodingAsUTF8: uniquedName).sha256Checksum,

Sources/PackageLoading/ManifestLoader.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -899,8 +899,8 @@ public final class ManifestLoader: ManifestLoaderProtocol {
899899
// We only allow the permissions which are absolutely necessary.
900900
if self.isManifestSandboxEnabled {
901901
let cacheDirectories = [self.databaseCacheDir, moduleCachePath].compactMap{ $0 }
902-
let strictness: Sandbox.Strictness = toolsVersion < .v5_3 ? .manifest_pre_53 : .default
903-
cmd = Sandbox.apply(command: cmd, strictness: strictness, writableDirectories: cacheDirectories)
902+
let strictness: Sandbox.Strictness = toolsVersion < .v5_3 ? .manifest_pre_53 : .default(writableTemporaryDirectory: false)
903+
cmd = Sandbox.apply(command: cmd, strictness: strictness, pathRules: cacheDirectories.map{ .writable(path: $0) })
904904
}
905905

906906
// Run the compiled manifest.

Sources/Workspace/DefaultPluginScriptRunner.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,10 @@ public struct DefaultPluginScriptRunner: PluginScriptRunner {
361361

362362
// Optionally wrap the command in a sandbox, which places some limits on what it can do. In particular, it blocks network access and restricts the paths to which the plugin can make file system changes. It does allow writing to temporary directories.
363363
if self.enableSandbox {
364-
command = Sandbox.apply(command: command, strictness: .writableTemporaryDirectory, writableDirectories: writableDirectories + [self.cacheDir], readOnlyDirectories: readOnlyDirectories)
364+
command = Sandbox.apply(
365+
command: command,
366+
strictness: .default(writableTemporaryDirectory: true),
367+
pathRules: readOnlyDirectories.map{ .readonly(path: $0) } + (writableDirectories + [self.cacheDir]).map{ .writable(path: $0) })
365368
}
366369

367370
// Create and configure a Process. We set the working directory to the cache directory, so that relative paths end up there.

Tests/BasicsTests/SandboxTests.swift

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ import XCTest
1717
final class SandboxTest: XCTestCase {
1818
func testSandboxOnAllPlatforms() throws {
1919
try withTemporaryDirectory { path in
20-
let command = Sandbox.apply(command: ["echo", "0"], strictness: .default, writableDirectories: [])
20+
let command = Sandbox.apply(
21+
command: ["echo", "0"],
22+
strictness: .default(writableTemporaryDirectory: false))
2123
XCTAssertNoThrow(try Process.checkNonZeroExit(arguments: command))
2224
}
2325
}
@@ -27,7 +29,9 @@ final class SandboxTest: XCTestCase {
2729
try XCTSkipIf(true, "test is only supported on macOS")
2830
#endif
2931

30-
let command = Sandbox.apply(command: ["ping", "-t", "1", "localhost"], strictness: .default, writableDirectories: [])
32+
let command = Sandbox.apply(
33+
command: ["ping", "-t", "1", "localhost"],
34+
strictness: .default(writableTemporaryDirectory: false))
3135

3236
XCTAssertThrowsError(try Process.checkNonZeroExit(arguments: command)) { error in
3337
guard case ProcessResult.Error.nonZeroExit(let result) = error else {
@@ -43,7 +47,10 @@ final class SandboxTest: XCTestCase {
4347
#endif
4448

4549
try withTemporaryDirectory { path in
46-
let command = Sandbox.apply(command: ["touch", path.appending(component: UUID().uuidString).pathString], strictness: .default, writableDirectories: [path])
50+
let command = Sandbox.apply(
51+
command: ["touch", path.appending(component: UUID().uuidString).pathString],
52+
strictness: .default(writableTemporaryDirectory: false),
53+
pathRules: [.writable(path: path)])
4754
XCTAssertNoThrow(try Process.checkNonZeroExit(arguments: command))
4855
}
4956
}
@@ -54,7 +61,9 @@ final class SandboxTest: XCTestCase {
5461
#endif
5562

5663
try withTemporaryDirectory { path in
57-
let command = Sandbox.apply(command: ["touch", path.appending(component: UUID().uuidString).pathString], strictness: .default, writableDirectories: [])
64+
let command = Sandbox.apply(
65+
command: ["touch", path.appending(component: UUID().uuidString).pathString],
66+
strictness: .default(writableTemporaryDirectory: false))
5867
XCTAssertThrowsError(try Process.checkNonZeroExit(arguments: command)) { error in
5968
guard case ProcessResult.Error.nonZeroExit(let result) = error else {
6069
return XCTFail("invalid error \(error)")
@@ -73,7 +82,9 @@ final class SandboxTest: XCTestCase {
7382
let file = path.appending(component: UUID().uuidString)
7483
XCTAssertNoThrow(try Process.checkNonZeroExit(arguments: ["touch", file.pathString]))
7584

76-
let command = Sandbox.apply(command: ["rm", file.pathString], strictness: .default, writableDirectories: [])
85+
let command = Sandbox.apply(
86+
command: ["rm", file.pathString],
87+
strictness: .default(writableTemporaryDirectory: false))
7788
XCTAssertThrowsError(try Process.checkNonZeroExit(arguments: command)) { error in
7889
guard case ProcessResult.Error.nonZeroExit(let result) = error else {
7990
return XCTFail("invalid error \(error)")
@@ -93,7 +104,9 @@ final class SandboxTest: XCTestCase {
93104
let file = path.appending(component: UUID().uuidString)
94105
XCTAssertNoThrow(try Process.checkNonZeroExit(arguments: ["touch", file.pathString]))
95106

96-
let command = Sandbox.apply(command: ["cat", file.pathString], strictness: .default, writableDirectories: [])
107+
let command = Sandbox.apply(
108+
command: ["cat", file.pathString],
109+
strictness: .default(writableTemporaryDirectory: false))
97110
XCTAssertNoThrow(try Process.checkNonZeroExit(arguments: command))
98111
}
99112
}
@@ -109,7 +122,9 @@ final class SandboxTest: XCTestCase {
109122
XCTAssertNoThrow(try Process.checkNonZeroExit(arguments: ["touch", file.pathString]))
110123
XCTAssertNoThrow(try Process.checkNonZeroExit(arguments: ["chmod", "+x", file.pathString]))
111124

112-
let command = Sandbox.apply(command: [file.pathString], strictness: .default, writableDirectories: [])
125+
let command = Sandbox.apply(
126+
command: [file.pathString],
127+
strictness: .default(writableTemporaryDirectory: false))
113128
XCTAssertNoThrow(try Process.checkNonZeroExit(arguments: command))
114129
}
115130
}
@@ -121,12 +136,16 @@ final class SandboxTest: XCTestCase {
121136

122137
// Try writing to the per-user temporary directory, which is under /var/folders/.../TemporaryItems.
123138
let tmpFile1 = NSTemporaryDirectory() + "/" + UUID().uuidString
124-
let command1 = Sandbox.apply(command: ["touch", tmpFile1], strictness: .writableTemporaryDirectory)
139+
let command1 = Sandbox.apply(
140+
command: ["touch", tmpFile1],
141+
strictness: .default(writableTemporaryDirectory: true))
125142
XCTAssertNoThrow(try Process.checkNonZeroExit(arguments: command1))
126143
try? FileManager.default.removeItem(atPath: tmpFile1)
127144

128145
let tmpFile2 = "/tmp" + "/" + UUID().uuidString
129-
let command2 = Sandbox.apply(command: ["touch", tmpFile2], strictness: .writableTemporaryDirectory)
146+
let command2 = Sandbox.apply(
147+
command: ["touch", tmpFile2],
148+
strictness: .default(writableTemporaryDirectory: true))
130149
XCTAssertNoThrow(try Process.checkNonZeroExit(arguments: command2))
131150
try? FileManager.default.removeItem(atPath: tmpFile2)
132151
}
@@ -139,13 +158,19 @@ final class SandboxTest: XCTestCase {
139158
try withTemporaryDirectory { tmpDir in
140159
// Check that we can write into it, but not to a read-only directory underneath it.
141160
let writableDir = tmpDir.appending(component: "ShouldBeWritable")
142-
let allowedCommand = Sandbox.apply(command: ["mkdir", writableDir.pathString], strictness: .default, writableDirectories: [tmpDir])
161+
let allowedCommand = Sandbox.apply(
162+
command: ["mkdir", writableDir.pathString],
163+
strictness: .default(writableTemporaryDirectory: false),
164+
pathRules: [.writable(path: tmpDir)])
143165
XCTAssertNoThrow(try Process.checkNonZeroExit(arguments: allowedCommand))
144166

145167
// Check that we cannot write into a read-only directory inside it.
146168
let readOnlyDir = writableDir.appending(component: "ShouldBeReadOnly")
147169
try localFileSystem.createDirectory(readOnlyDir)
148-
let deniedCommand = Sandbox.apply(command: ["touch", readOnlyDir.pathString], strictness: .default, writableDirectories: [tmpDir], readOnlyDirectories: [readOnlyDir])
170+
let deniedCommand = Sandbox.apply(
171+
command: ["touch", readOnlyDir.pathString],
172+
strictness: .default(writableTemporaryDirectory: false),
173+
pathRules: [.writable(path: tmpDir), .readonly(path: readOnlyDir)])
149174
XCTAssertThrowsError(try Process.checkNonZeroExit(arguments: deniedCommand)) { error in
150175
guard case ProcessResult.Error.nonZeroExit(let result) = error else {
151176
return XCTFail("invalid error \(error)")

0 commit comments

Comments
 (0)