Skip to content

Commit 6c2ccaf

Browse files
WASI: Implement path_open with proper symlink resolution
1 parent d8dcb44 commit 6c2ccaf

File tree

4 files changed

+322
-20
lines changed

4 files changed

+322
-20
lines changed

Sources/WASI/Platform/PlatformTypes.swift

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -156,15 +156,23 @@ extension WASIAbi.Errno {
156156
do {
157157
return try body()
158158
} catch let errno as Errno {
159-
guard let error = WASIAbi.Errno(platformErrno: errno) else {
160-
throw WASIError(description: "Unknown underlying OS error: \(errno)")
161-
}
162-
throw error
159+
throw try WASIAbi.Errno(platformErrno: errno)
160+
}
161+
}
162+
163+
init(platformErrno: CInt) throws {
164+
try self.init(platformErrno: SystemPackage.Errno(rawValue: platformErrno))
165+
}
166+
167+
init(platformErrno: Errno) throws {
168+
guard let error = WASIAbi.Errno(_platformErrno: platformErrno) else {
169+
throw WASIError(description: "Unknown underlying OS error: \(platformErrno)")
163170
}
171+
self = error
164172
}
165173

166-
init?(platformErrno: SystemPackage.Errno) {
167-
switch platformErrno {
174+
private init?(_platformErrno: SystemPackage.Errno) {
175+
switch _platformErrno {
168176
case .permissionDenied: self = .EPERM
169177
case .notPermitted: self = .EPERM
170178
case .noSuchFileOrDirectory: self = .ENOENT

Sources/WASI/Platform/SandboxPrimitives/Open.swift

Lines changed: 110 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
import SystemExtras
22
import SystemPackage
33

4+
#if canImport(Darwin)
5+
import Darwin
6+
#elseif canImport(Glibc)
7+
import CSystem
8+
import Glibc
9+
#elseif canImport(Musl)
10+
import CSystem
11+
import Musl
12+
#elseif os(Windows)
13+
import CSystem
14+
import ucrt
15+
#else
16+
#error("Unsupported Platform")
17+
#endif
18+
419
struct PathResolution {
520
private let mode: FileDescriptor.AccessMode
621
private let options: FileDescriptor.OpenOptions
@@ -10,7 +25,20 @@ struct PathResolution {
1025
private let path: FilePath
1126
private var openDirectories: [FileDescriptor]
1227
/// Reverse-ordered remaining path components
28+
/// File name appears first, then parent directories.
29+
/// e.g. `a/b/c` -> ["c", "b", "a"]
30+
/// This ordering is just to avoid dropFirst() on Array.
1331
private var components: FilePath.ComponentView
32+
private var resolvedSymlinks: Int = 0
33+
34+
private static var MAX_SYMLINKS: Int {
35+
// Linux defines MAXSYMLINKS as 40, but on darwin platforms, it's 32.
36+
// Take a single conservative value here to avoid platform-specific
37+
// behavior as much as possible.
38+
// * https://github.com/apple-oss-distributions/xnu/blob/8d741a5de7ff4191bf97d57b9f54c2f6d4a15585/bsd/sys/param.h#L207
39+
// * https://github.com/torvalds/linux/blob/850925a8133c73c4a2453c360b2c3beb3bab67c9/include/linux/namei.h#L13
40+
return 32
41+
}
1442

1543
init(
1644
baseDirFd: FileDescriptor,
@@ -33,39 +61,107 @@ struct PathResolution {
3361
// no more parent directory means too many `..`
3462
throw WASIAbi.Errno.EPERM
3563
}
64+
try self.baseFd.close()
3665
self.baseFd = lastDirectory
3766
}
3867

3968
mutating func regular(component: FilePath.Component) throws {
40-
let options: FileDescriptor.OpenOptions
69+
var options: FileDescriptor.OpenOptions = []
70+
#if !os(Windows)
71+
// First, try without following symlinks as a fast path.
72+
// If it's actually a symlink and options don't have O_NOFOLLOW,
73+
// we'll try again with interpreting resolved symlink.
74+
options.insert(.noFollow)
75+
#endif
4176
let mode: FileDescriptor.AccessMode
42-
if !self.components.isEmpty {
43-
var intermediateOptions: FileDescriptor.OpenOptions = []
4477

78+
if !self.components.isEmpty {
4579
#if !os(Windows)
4680
// When trying to open an intermediate directory,
4781
// we can assume it's directory.
48-
intermediateOptions.insert(.directory)
49-
// FIXME: Resolve symlink in safe way
50-
intermediateOptions.insert(.noFollow)
82+
options.insert(.directory)
5183
#endif
52-
options = intermediateOptions
5384
mode = .readOnly
5485
} else {
55-
options = self.options
86+
options.formUnion(self.options)
5687
mode = self.mode
5788
}
5889

5990
try WASIAbi.Errno.translatingPlatformErrno {
60-
let newFd = try self.baseFd.open(
61-
at: FilePath(root: nil, components: component),
62-
mode, options: options, permissions: permissions
63-
)
64-
self.openDirectories.append(self.baseFd)
65-
self.baseFd = newFd
91+
do {
92+
let newFd = try self.baseFd.open(
93+
at: FilePath(root: nil, components: component),
94+
mode, options: options, permissions: permissions
95+
)
96+
self.openDirectories.append(self.baseFd)
97+
self.baseFd = newFd
98+
return
99+
} catch let openErrno as Errno {
100+
if self.options.contains(.noFollow) {
101+
// If "open" failed with O_NOFOLLOW, no need to retry.
102+
throw openErrno
103+
}
104+
105+
// If "open" failed and it might be a symlink, try again with following symlink.
106+
107+
// Check if it's a symlink by fstatat(2).
108+
//
109+
// NOTE: `errno` has enough information to check if the component is a symlink,
110+
// but the value is platform-specific (e.g. ELOOP on POSIX standards, but EMLINK
111+
// on BSD family), so we conservatively check it by fstatat(2).
112+
let attrs = try self.baseFd.attributes(
113+
at: FilePath(root: nil, components: component), options: [.noFollow]
114+
)
115+
guard attrs.fileType.isSymlink else {
116+
// openat(2) failed, fstatat(2) succeeded, and it said it's not a symlink.
117+
// If it's not a symlink, the error is not due to symlink following
118+
// but other reasons, so just throw the error.
119+
// e.g. open with O_DIRECTORY on a regular file.
120+
throw openErrno
121+
}
122+
123+
try self.symlink(component: component)
124+
}
66125
}
67126
}
68127

128+
mutating func symlink(component: FilePath.Component) throws {
129+
/// Thin wrapper around readlinkat(2)
130+
func _readlinkat(_ fd: CInt, _ path: UnsafePointer<CChar>) throws -> FilePath {
131+
var buffer = [CChar](repeating: 0, count: Int(PATH_MAX))
132+
let length = buffer.withUnsafeMutableBufferPointer { buffer in
133+
buffer.withMemoryRebound(to: Int8.self) { buffer in
134+
readlinkat(fd, path, buffer.baseAddress, buffer.count)
135+
}
136+
}
137+
guard length >= 0 else {
138+
throw try WASIAbi.Errno(platformErrno: errno)
139+
}
140+
return FilePath(String(cString: buffer))
141+
}
142+
143+
guard resolvedSymlinks < Self.MAX_SYMLINKS else {
144+
throw WASIAbi.Errno.ELOOP
145+
}
146+
147+
// If it's a symlink, readlink(2) and check it doesn't escape sandbox.
148+
let linkPath = try component.withPlatformString {
149+
return try _readlinkat(self.baseFd.rawValue, $0)
150+
}
151+
152+
guard !linkPath.isAbsolute else {
153+
// Ban absolute symlink to avoid sandbox-escaping.
154+
throw WASIAbi.Errno.EPERM
155+
}
156+
157+
// Increment the number of resolved symlinks to prevent infinite
158+
// link loop.
159+
resolvedSymlinks += 1
160+
161+
// Add resolved path to the worklist.
162+
self.components.append(contentsOf: linkPath.components.reversed())
163+
}
164+
69165
mutating func resolve() throws -> FileDescriptor {
70166
if path.isAbsolute {
71167
// POSIX openat(2) interprets absolute path ignoring base directory fd

Tests/WASITests/TestSupport.swift

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import Foundation
2+
3+
enum TestSupport {
4+
struct Error: Swift.Error, CustomStringConvertible {
5+
let description: String
6+
7+
init(description: String) {
8+
self.description = description
9+
}
10+
11+
init(errno: Int32) {
12+
self.init(description: String(cString: strerror(errno)))
13+
}
14+
}
15+
16+
class TemporaryDirectory {
17+
let path: String
18+
var url: URL { URL(fileURLWithPath: path) }
19+
20+
init() throws {
21+
let tempdir = URL(fileURLWithPath: NSTemporaryDirectory())
22+
let templatePath = tempdir.appendingPathComponent("WasmKit.XXXXXX")
23+
var template = [UInt8](templatePath.path.utf8).map({ Int8($0) }) + [Int8(0)]
24+
25+
#if os(Windows)
26+
if _mktemp_s(&template, template.count) != 0 {
27+
throw Error(errno: errno)
28+
}
29+
if _mkdir(template) != 0 {
30+
throw Error(errno: errno)
31+
}
32+
#else
33+
if mkdtemp(&template) == nil {
34+
throw Error(errno: errno)
35+
}
36+
#endif
37+
38+
self.path = String(cString: template)
39+
}
40+
41+
func createDir(at relativePath: String) throws {
42+
let directoryURL = url.appendingPathComponent(relativePath)
43+
try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil)
44+
}
45+
46+
func createFile(at relativePath: String, contents: String) throws {
47+
let fileURL = url.appendingPathComponent(relativePath)
48+
guard let data = contents.data(using: .utf8) else { return }
49+
FileManager.default.createFile(atPath: fileURL.path, contents: data, attributes: nil)
50+
}
51+
52+
func createSymlink(at relativePath: String, to target: String) throws {
53+
let linkURL = url.appendingPathComponent(relativePath)
54+
try FileManager.default.createSymbolicLink(
55+
atPath: linkURL.path,
56+
withDestinationPath: target
57+
)
58+
}
59+
60+
deinit {
61+
_ = try? FileManager.default.removeItem(atPath: path)
62+
}
63+
}
64+
}

Tests/WASITests/WASITests.swift

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import XCTest
2+
3+
@testable import WASI
4+
5+
final class WASITests: XCTestCase {
6+
func testPathOpen() throws {
7+
let t = try TestSupport.TemporaryDirectory()
8+
9+
try t.createDir(at: "External")
10+
try t.createDir(at: "External/secret-dir-b")
11+
try t.createFile(at: "External/secret-a.txt", contents: "Secret A")
12+
try t.createFile(at: "External/secret-dir-b/secret-c.txt", contents: "Secret C")
13+
try t.createDir(at: "Sandbox")
14+
try t.createFile(at: "Sandbox/hello.txt", contents: "Hello")
15+
try t.createSymlink(at: "Sandbox/link-hello.txt", to: "hello.txt")
16+
try t.createDir(at: "Sandbox/world.dir")
17+
try t.createSymlink(at: "Sandbox/link-world.dir", to: "world.dir")
18+
try t.createSymlink(at: "Sandbox/link-external-secret-a.txt", to: "../External/secret-a.txt")
19+
try t.createSymlink(at: "Sandbox/link-secret-dir-b", to: "../External/secret-dir-b")
20+
try t.createSymlink(at: "Sandbox/link-updown-hello.txt", to: "../Sandbox/link-updown-hello.txt")
21+
try t.createSymlink(at: "Sandbox/link-external-non-existent.txt", to: "../External/non-existent.txt")
22+
try t.createSymlink(at: "Sandbox/link-root", to: "/")
23+
try t.createSymlink(at: "Sandbox/link-loop.txt", to: "link-loop.txt")
24+
25+
let wasi = try WASIBridgeToHost(
26+
preopens: ["/Sandbox": t.url.appendingPathComponent("Sandbox").path]
27+
)
28+
let mntFd: WASIAbi.Fd = 3
29+
30+
func assertResolve(_ path: String, followSymlink: Bool, directory: Bool = false) throws {
31+
let fd = try wasi.path_open(
32+
dirFd: mntFd,
33+
dirFlags: followSymlink ? [.SYMLINK_FOLLOW] : [],
34+
path: path,
35+
oflags: directory ? [.DIRECTORY] : [],
36+
fsRightsBase: .DIRECTORY_BASE_RIGHTS,
37+
fsRightsInheriting: .DIRECTORY_INHERITING_RIGHTS,
38+
fdflags: []
39+
)
40+
try wasi.fd_close(fd: fd)
41+
}
42+
43+
func assertNotResolve(
44+
_ path: String,
45+
followSymlink: Bool,
46+
directory: Bool = false,
47+
file: StaticString = #file,
48+
line: UInt = #line,
49+
_ checkError: ((WASIAbi.Errno) throws -> Void)?
50+
) throws {
51+
do {
52+
_ = try wasi.path_open(
53+
dirFd: mntFd,
54+
dirFlags: followSymlink ? [.SYMLINK_FOLLOW] : [],
55+
path: path,
56+
oflags: directory ? [.DIRECTORY] : [],
57+
fsRightsBase: .DIRECTORY_BASE_RIGHTS,
58+
fsRightsInheriting: .DIRECTORY_INHERITING_RIGHTS,
59+
fdflags: []
60+
)
61+
XCTFail("Expected not to be able to open \(path)", file: file, line: line)
62+
} catch {
63+
guard let error = error as? WASIAbi.Errno else {
64+
XCTFail("Expected WASIAbi.Errno error but got \(error)", file: file, line: line)
65+
return
66+
}
67+
try checkError?(error)
68+
}
69+
}
70+
71+
try assertNotResolve("non-existent.txt", followSymlink: false) { error in
72+
XCTAssertEqual(error, .ENOENT)
73+
}
74+
75+
try assertResolve("link-hello.txt", followSymlink: true)
76+
try assertNotResolve("link-hello.txt", followSymlink: false) { error in
77+
XCTAssertEqual(error, .ELOOP)
78+
}
79+
try assertNotResolve("link-hello.txt", followSymlink: true, directory: true) { error in
80+
XCTAssertEqual(error, .ENOTDIR)
81+
}
82+
83+
try assertNotResolve("link-hello.txt/", followSymlink: true) { error in
84+
XCTAssertEqual(error, .ENOTDIR)
85+
}
86+
87+
try assertResolve("link-world.dir", followSymlink: true)
88+
try assertNotResolve("link-world.dir", followSymlink: false) { error in
89+
XCTAssertEqual(error, .ELOOP)
90+
}
91+
92+
try assertNotResolve("link-external-secret-a.txt", followSymlink: true) { error in
93+
XCTAssertEqual(error, .EPERM)
94+
}
95+
try assertNotResolve("link-external-secret-a.txt", followSymlink: false) { error in
96+
XCTAssertEqual(error, .ELOOP)
97+
}
98+
99+
try assertNotResolve("link-external-non-existent.txt", followSymlink: true) { error in
100+
XCTAssertEqual(error, .EPERM)
101+
}
102+
try assertNotResolve("link-external-non-existent.txt", followSymlink: false) { error in
103+
XCTAssertEqual(error, .ELOOP)
104+
}
105+
106+
try assertNotResolve("link-updown-hello.txt", followSymlink: true) { error in
107+
XCTAssertEqual(error, .EPERM)
108+
}
109+
try assertNotResolve("link-updown-hello.txt", followSymlink: false) { error in
110+
XCTAssertEqual(error, .ELOOP)
111+
}
112+
113+
try assertNotResolve("link-secret-dir-b/secret-c.txt", followSymlink: true) { error in
114+
XCTAssertEqual(error, .EPERM)
115+
}
116+
try assertNotResolve("link-secret-dir-b/secret-c.txt", followSymlink: false) { error in
117+
XCTAssertEqual(error, .ENOTDIR)
118+
}
119+
120+
try assertNotResolve("link-root", followSymlink: true) { error in
121+
XCTAssertEqual(error, .EPERM)
122+
}
123+
try assertNotResolve("link-root", followSymlink: false) { error in
124+
XCTAssertEqual(error, .ELOOP)
125+
}
126+
127+
try assertNotResolve("link-loop.txt", followSymlink: false) { error in
128+
XCTAssertEqual(error, .ELOOP)
129+
}
130+
try assertNotResolve("link-loop.txt", followSymlink: true) { error in
131+
XCTAssertEqual(error, .ELOOP)
132+
}
133+
}
134+
}

0 commit comments

Comments
 (0)