Skip to content

Commit 70179be

Browse files
WASI: Implement path_open with proper symlink resolution
1 parent d8dcb44 commit 70179be

File tree

4 files changed

+335
-20
lines changed

4 files changed

+335
-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: 120 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,117 @@ 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 os(Windows)
101+
// Windows doesn't have O_NOFOLLOW, so we can't retry with following symlink.
102+
throw openErrno
103+
#else
104+
if self.options.contains(.noFollow) {
105+
// If "open" failed with O_NOFOLLOW, no need to retry.
106+
throw openErrno
107+
}
108+
109+
// If "open" failed and it might be a symlink, try again with following symlink.
110+
111+
// Check if it's a symlink by fstatat(2).
112+
//
113+
// NOTE: `errno` has enough information to check if the component is a symlink,
114+
// but the value is platform-specific (e.g. ELOOP on POSIX standards, but EMLINK
115+
// on BSD family), so we conservatively check it by fstatat(2).
116+
let attrs = try self.baseFd.attributes(
117+
at: FilePath(root: nil, components: component), options: [.noFollow]
118+
)
119+
guard attrs.fileType.isSymlink else {
120+
// openat(2) failed, fstatat(2) succeeded, and it said it's not a symlink.
121+
// If it's not a symlink, the error is not due to symlink following
122+
// but other reasons, so just throw the error.
123+
// e.g. open with O_DIRECTORY on a regular file.
124+
throw openErrno
125+
}
126+
127+
try self.symlink(component: component)
128+
#endif
129+
}
66130
}
67131
}
68132

133+
#if !os(Windows)
134+
mutating func symlink(component: FilePath.Component) throws {
135+
/// Thin wrapper around readlinkat(2)
136+
func _readlinkat(_ fd: CInt, _ path: UnsafePointer<CChar>) throws -> FilePath {
137+
var buffer = [CChar](repeating: 0, count: Int(PATH_MAX))
138+
let length = try buffer.withUnsafeMutableBufferPointer { buffer in
139+
try buffer.withMemoryRebound(to: Int8.self) { buffer in
140+
guard let bufferBase = buffer.baseAddress else {
141+
throw WASIAbi.Errno.EINVAL
142+
}
143+
return readlinkat(fd, path, bufferBase, buffer.count)
144+
}
145+
}
146+
guard length >= 0 else {
147+
throw try WASIAbi.Errno(platformErrno: errno)
148+
}
149+
return FilePath(String(cString: buffer))
150+
}
151+
152+
guard resolvedSymlinks < Self.MAX_SYMLINKS else {
153+
throw WASIAbi.Errno.ELOOP
154+
}
155+
156+
// If it's a symlink, readlink(2) and check it doesn't escape sandbox.
157+
let linkPath = try component.withPlatformString {
158+
return try _readlinkat(self.baseFd.rawValue, $0)
159+
}
160+
161+
guard !linkPath.isAbsolute else {
162+
// Ban absolute symlink to avoid sandbox-escaping.
163+
throw WASIAbi.Errno.EPERM
164+
}
165+
166+
// Increment the number of resolved symlinks to prevent infinite
167+
// link loop.
168+
resolvedSymlinks += 1
169+
170+
// Add resolved path to the worklist.
171+
self.components.append(contentsOf: linkPath.components.reversed())
172+
}
173+
#endif
174+
69175
mutating func resolve() throws -> FileDescriptor {
70176
if path.isAbsolute {
71177
// 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+
}

0 commit comments

Comments
 (0)