Files

320 lines
17 KiB
Swift

//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationOS
import Foundation
import Testing
@testable import ContainerBuild
struct TestCase {
let pattern: String
let fileName: String
let expectSuccess: Bool
}
// test cases adapted from https://github.com/moby/patternmatcher/tree/main
let globTestCases = [
TestCase(pattern: "*", fileName: "test.go", expectSuccess: true),
TestCase(pattern: "**", fileName: "test.go", expectSuccess: true),
TestCase(pattern: "**", fileName: "file", expectSuccess: true),
TestCase(pattern: "*.go", fileName: "test.go", expectSuccess: true),
TestCase(pattern: "a.|)$(}+{bc", fileName: "a.|)$(}+{bc", expectSuccess: true),
TestCase(pattern: "abc.def", fileName: "abcdef", expectSuccess: false),
TestCase(pattern: "abc.def", fileName: "abc.def", expectSuccess: true),
TestCase(pattern: "abc.def", fileName: "abcZdef", expectSuccess: false),
TestCase(pattern: "abc?def", fileName: "abcZdef", expectSuccess: true),
TestCase(pattern: "abc?def", fileName: "abcdef", expectSuccess: false),
TestCase(pattern: "a[b-d]e", fileName: "ae", expectSuccess: false),
TestCase(pattern: "a[b-d]e", fileName: "ace", expectSuccess: true),
TestCase(pattern: "a[b-d]e", fileName: "aae", expectSuccess: false),
TestCase(pattern: "a[^b-d]e", fileName: "aze", expectSuccess: true),
TestCase(pattern: "a[\\^b-d]e", fileName: "abe", expectSuccess: true),
TestCase(pattern: "a[\\^b-d]e", fileName: "aze", expectSuccess: false),
]
let errorGlobTestCases = [
TestCase(pattern: "[]a]", fileName: "]", expectSuccess: true),
TestCase(pattern: "[", fileName: "a", expectSuccess: true),
TestCase(pattern: "[^", fileName: "a", expectSuccess: true),
TestCase(pattern: "[^bc", fileName: "a", expectSuccess: true),
TestCase(pattern: "a[", fileName: "a", expectSuccess: true),
TestCase(pattern: "a[", fileName: "ab", expectSuccess: true),
]
let testCases = [
TestCase(pattern: "*", fileName: "test/test.go", expectSuccess: true),
TestCase(pattern: "**.go", fileName: "test/test.go", expectSuccess: true),
TestCase(pattern: "**file", fileName: "test/file", expectSuccess: true),
TestCase(pattern: "**/*", fileName: "test/test.go", expectSuccess: true),
TestCase(pattern: "**/", fileName: "file", expectSuccess: true),
TestCase(pattern: "**/", fileName: "file/", expectSuccess: true),
TestCase(pattern: "**", fileName: "file", expectSuccess: true),
TestCase(pattern: "**", fileName: "file/", expectSuccess: true),
TestCase(pattern: "**", fileName: "dir/file", expectSuccess: true),
TestCase(pattern: "**/", fileName: "dir/file", expectSuccess: true),
TestCase(pattern: "**", fileName: "dir/file/", expectSuccess: true),
TestCase(pattern: "**/", fileName: "dir/file/", expectSuccess: true),
TestCase(pattern: "**/**", fileName: "dir/file", expectSuccess: true),
TestCase(pattern: "**/**", fileName: "dir/file/", expectSuccess: true),
TestCase(pattern: "dir/**", fileName: "dir/file", expectSuccess: true),
TestCase(pattern: "dir/**", fileName: "dir/file/", expectSuccess: true),
TestCase(pattern: "dir/**", fileName: "dir/dir2/file", expectSuccess: true),
TestCase(pattern: "dir/**", fileName: "dir/dir2/file/", expectSuccess: true),
TestCase(pattern: "**/dir", fileName: "dir", expectSuccess: true),
TestCase(pattern: "**/dir", fileName: "dir/file", expectSuccess: true),
TestCase(pattern: "**/dir2/*", fileName: "dir/dir2/file", expectSuccess: true),
TestCase(pattern: "**/dir2/*", fileName: "dir/dir2/file/", expectSuccess: true),
TestCase(pattern: "**/dir2/**", fileName: "dir/dir2/dir3/file", expectSuccess: true),
TestCase(pattern: "**/dir2/**", fileName: "dir/dir2/dir3/file/", expectSuccess: true),
TestCase(pattern: "**file", fileName: "file", expectSuccess: true),
TestCase(pattern: "**file", fileName: "dir/file", expectSuccess: true),
TestCase(pattern: "**/file", fileName: "dir/file", expectSuccess: true),
TestCase(pattern: "**file", fileName: "dir/dir/file", expectSuccess: true),
TestCase(pattern: "**/file", fileName: "dir/dir/file", expectSuccess: true),
TestCase(pattern: "**/file*", fileName: "dir/dir/file", expectSuccess: true),
TestCase(pattern: "**/file*", fileName: "dir/dir/file.txt", expectSuccess: true),
TestCase(pattern: "**/file*txt", fileName: "dir/dir/file.txt", expectSuccess: true),
TestCase(pattern: "**/file*.txt", fileName: "dir/dir/file.txt", expectSuccess: true),
TestCase(pattern: "**/file*.txt*", fileName: "dir/dir/file.txt", expectSuccess: true),
TestCase(pattern: "**/**/*.txt", fileName: "dir/dir/file.txt", expectSuccess: true),
TestCase(pattern: "**/**/*.txt2", fileName: "dir/dir/file.txt", expectSuccess: false),
TestCase(pattern: "**/*.txt", fileName: "file.txt", expectSuccess: true),
TestCase(pattern: "**/**/*.txt", fileName: "file.txt", expectSuccess: true),
TestCase(pattern: "a**/*.txt", fileName: "a/file.txt", expectSuccess: true),
TestCase(pattern: "a**/*.txt", fileName: "a/dir/file.txt", expectSuccess: true),
TestCase(pattern: "a**/*.txt", fileName: "a/dir/dir/file.txt", expectSuccess: true),
TestCase(pattern: "a/*.txt", fileName: "a/dir/file.txt", expectSuccess: false),
TestCase(pattern: "a/*.txt", fileName: "a/file.txt", expectSuccess: true),
TestCase(pattern: "a/*.txt**", fileName: "a/file.txt", expectSuccess: true),
TestCase(pattern: ".*", fileName: ".foo", expectSuccess: true),
TestCase(pattern: ".*", fileName: "foo", expectSuccess: false),
TestCase(pattern: "abc.def", fileName: "abcdef", expectSuccess: false),
TestCase(pattern: "abc.def", fileName: "abc.def", expectSuccess: true),
TestCase(pattern: "abc.def", fileName: "abcZdef", expectSuccess: false),
TestCase(pattern: "abc?def", fileName: "abcZdef", expectSuccess: true),
TestCase(pattern: "abc?def", fileName: "abcdef", expectSuccess: false),
TestCase(pattern: "**/foo/bar", fileName: "foo/bar", expectSuccess: true),
TestCase(pattern: "**/foo/bar", fileName: "dir/foo/bar", expectSuccess: true),
TestCase(pattern: "**/foo/bar", fileName: "dir/dir2/foo/bar", expectSuccess: true),
TestCase(pattern: "abc/**", fileName: "abc/def", expectSuccess: true),
TestCase(pattern: "abc/**", fileName: "abc/def/ghi", expectSuccess: true),
TestCase(pattern: "**/.foo", fileName: ".foo", expectSuccess: true),
TestCase(pattern: "**/.foo", fileName: "bar.foo", expectSuccess: false),
TestCase(pattern: "./bar.*", fileName: "bar.foo", expectSuccess: true),
TestCase(pattern: "./bar.*/", fileName: "bar.foo", expectSuccess: true),
TestCase(pattern: "a(b)c/def", fileName: "a(b)c/def", expectSuccess: true),
TestCase(pattern: "a(b)c/def", fileName: "a(b)c/xyz", expectSuccess: false),
TestCase(pattern: "a.|)$(}+{bc", fileName: "a.|)$(}+{bc", expectSuccess: true),
TestCase(pattern: "dist/proxy.py-2.4.0rc3.dev36+g08acad9-py3-none-any.whl", fileName: "dist/proxy.py-2.4.0rc3.dev36+g08acad9-py3-none-any.whl", expectSuccess: true),
TestCase(pattern: "dist/*.whl", fileName: "dist/proxy.py-2.4.0rc3.dev36+g08acad9-py3-none-any.whl", expectSuccess: true),
]
@Suite struct TestGlobber {
@Test("All glob patterns match", arguments: globTestCases)
func testGlobMatching(_ test: TestCase) throws {
let globber = Globber(URL(fileURLWithPath: "/"))
let found = try globber.glob(test.fileName, test.pattern)
#expect(found == test.expectSuccess, "expected found to be \(test.expectSuccess), instead got \(found)")
}
@Test("Invalid computed regex patterns throw error", arguments: errorGlobTestCases)
func testInvalidGlob(_ test: TestCase) throws {
let globber = Globber(URL(fileURLWithPath: "/"))
#expect(throws: (any Error).self) {
try globber.glob(test.fileName, test.pattern)
}
}
@Test("All expected patterns match", arguments: testCases)
func testExpectedPatterns(_ test: TestCase) throws {
let charactersToTrim = CharacterSet(charactersIn: "/")
let components = test.fileName
.trimmingCharacters(in: charactersToTrim)
.components(separatedBy: "/")
// tempDir is the directory we're making the files or nested files in
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
var fileDir: URL = tempDir
// testDir is the directory before the last component that we need to create
components.dropLast().forEach { component in
var d = fileDir
if component == ".." {
d = fileDir.deletingLastPathComponent()
} else if component != "." {
d = fileDir.appendingPathComponent(component)
}
#expect(throws: Never.self) {
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
}
fileDir = d
}
#expect(throws: Never.self) {
try FileManager.default.createDirectory(at: fileDir, withIntermediateDirectories: true)
}
let testFile = fileDir.appendingPathComponent(components.last!)
#expect(throws: Never.self) {
try "".write(to: testFile, atomically: true, encoding: .utf8)
}
defer {
try? FileManager.default.removeItem(at: tempDir)
}
let globber = Globber(tempDir)
#expect(throws: Never.self) {
try globber.match(test.pattern)
let found: Bool = !globber.results.isEmpty
let onDisk = FileManager.default.enumerator(at: tempDir, includingPropertiesForKeys: nil)?.allObjects ?? []
#expect(found == test.expectSuccess, "expected match to be \(test.expectSuccess), instead got \(found) \(onDisk)")
}
}
@Test("Test the base directory is not include in results")
func testBaseDirNotIncluded() throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let testDir = tempDir.appendingPathComponent("abc")
#expect(throws: Never.self) {
try FileManager.default.createDirectory(at: testDir, withIntermediateDirectories: true)
}
defer {
try? FileManager.default.removeItem(at: tempDir)
}
let globber = Globber(testDir)
#expect(throws: Never.self) {
try globber.match("abc/**")
#expect(globber.results.isEmpty, "expected to find no matches, instead found \(globber.results)")
}
}
// MARK: - Directory symlink traversal
//
// Globber must be able to descend through a directory symlink whose fully
// resolved target is still inside the match root (the same containment
// check BuildFSSync.read()/info() apply before serving content), while
// continuing to treat a symlink that escapes the root as a leaf with no
// children. See the discussion on
// https://github.com/apple/container-ghsa-2v2q-4q35-h585/pull/2.
@Test("Match descends through an in-context directory symlink")
func testMatchFollowsInContextDirectorySymlink() throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let real = tempDir.appendingPathComponent("real")
let link = tempDir.appendingPathComponent("link")
let file = real.appendingPathComponent("file.txt")
try FileManager.default.createDirectory(at: real, withIntermediateDirectories: true)
try "hello".write(to: file, atomically: true, encoding: .utf8)
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: real)
defer { try? FileManager.default.removeItem(at: tempDir) }
let globber = Globber(tempDir)
try globber.match("link/file.txt")
// Compare by name rather than exact path: on macOS /var is itself a
// symlink to /private/var, and FileManager.contentsOfDirectory vs.
// URL.resolvingSymlinksInPath() normalize that inconsistently, so a
// temp-dir-rooted URL built by hand won't reliably string-match a URL
// Globber discovered through the real filesystem APIs.
#expect(
globber.results.contains { $0.isSymlink && $0.lastPathComponent == "link" },
"expected the symlink itself to be preserved in results, got \(globber.results)"
)
#expect(
globber.results.contains { $0.lastPathComponent == "file.txt" },
"expected the resolved physical file to be present in results, got \(globber.results)"
)
}
@Test("Recursive glob descends through an in-context directory symlink")
func testMatchRecursiveGlobFollowsInContextDirectorySymlink() throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let real = tempDir.appendingPathComponent("real")
let link = tempDir.appendingPathComponent("link")
let file = real.appendingPathComponent("file.txt")
try FileManager.default.createDirectory(at: real, withIntermediateDirectories: true)
try "hello".write(to: file, atomically: true, encoding: .utf8)
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: real)
defer { try? FileManager.default.removeItem(at: tempDir) }
let globber = Globber(tempDir)
try globber.match("link/**")
#expect(globber.results.contains { $0.isSymlink && $0.lastPathComponent == "link" })
#expect(globber.results.contains { $0.lastPathComponent == "real" })
#expect(globber.results.contains { $0.lastPathComponent == "file.txt" })
}
@Test("Match does not descend through a directory symlink that escapes the root")
func testMatchDoesNotDescendThroughSymlinkEscapingRoot() throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let outsideDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let link = tempDir.appendingPathComponent("link")
let secret = outsideDir.appendingPathComponent("secret.txt")
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: outsideDir, withIntermediateDirectories: true)
try "supersecret".write(to: secret, atomically: true, encoding: .utf8)
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: outsideDir)
defer {
try? FileManager.default.removeItem(at: tempDir)
try? FileManager.default.removeItem(at: outsideDir)
}
let globber = Globber(tempDir)
try globber.match("link/secret.txt")
#expect(globber.results.isEmpty, "expected no match through a symlink escaping the root, got \(globber.results)")
}
@Test("Recursive glob through a symlink escaping the root never leaks out-of-root files")
func testMatchRecursiveGlobDoesNotLeakOutsideRoot() throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let outsideDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let link = tempDir.appendingPathComponent("link")
let secret = outsideDir.appendingPathComponent("secret.txt")
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: outsideDir, withIntermediateDirectories: true)
try "supersecret".write(to: secret, atomically: true, encoding: .utf8)
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: outsideDir)
defer {
try? FileManager.default.removeItem(at: tempDir)
try? FileManager.default.removeItem(at: outsideDir)
}
let globber = Globber(tempDir)
try globber.match("link/**")
let leaked = globber.results.filter { url in
guard !url.isSymlink else { return false }
let resolved = url.resolvingSymlinksInPath()
return !tempDir.parentOf(resolved) && resolved.cleanPath != tempDir.cleanPath
}
#expect(leaked.isEmpty, "match() produced non-symlink results that physically resolve outside the root: \(leaked)")
}
}