chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization 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 Foundation
|
||||
import Testing
|
||||
|
||||
@testable import ContainerizationOS
|
||||
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#elseif canImport(Musl)
|
||||
import Musl
|
||||
#endif
|
||||
|
||||
@Suite("BidirectionalRelay tests")
|
||||
final class BidirectionalRelayTests {
|
||||
|
||||
/// Creates a Unix domain socket pair and returns (fd0, fd1).
|
||||
private func makeSocketPair() throws -> (Int32, Int32) {
|
||||
var fds: [Int32] = [0, 0]
|
||||
#if os(macOS)
|
||||
let result = socketpair(AF_UNIX, SOCK_STREAM, 0, &fds)
|
||||
#else
|
||||
let result = socketpair(AF_UNIX, Int32(SOCK_STREAM.rawValue), 0, &fds)
|
||||
#endif
|
||||
try #require(result == 0, "socketpair should succeed, errno: \(errno)")
|
||||
return (fds[0], fds[1])
|
||||
}
|
||||
|
||||
/// Writes all bytes to a file descriptor, retrying on partial writes.
|
||||
private func writeAll(fd: Int32, data: [UInt8]) throws {
|
||||
var offset = 0
|
||||
while offset < data.count {
|
||||
let n = data.withUnsafeBufferPointer { buf in
|
||||
write(fd, buf.baseAddress!.advanced(by: offset), data.count - offset)
|
||||
}
|
||||
try #require(n > 0, "write failed, errno: \(errno)")
|
||||
offset += n
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads exactly `count` bytes from a file descriptor with a timeout.
|
||||
/// Returns the data read, or nil if the timeout expires.
|
||||
private func readWithTimeout(fd: Int32, count: Int, timeoutSeconds: Double) -> [UInt8]? {
|
||||
// Set fd to non-blocking for poll-based reading.
|
||||
let flags = fcntl(fd, F_GETFL)
|
||||
_ = fcntl(fd, F_SETFL, flags | O_NONBLOCK)
|
||||
defer { _ = fcntl(fd, F_SETFL, flags) }
|
||||
|
||||
var result = [UInt8](repeating: 0, count: count)
|
||||
var totalRead = 0
|
||||
let deadline = Date().addingTimeInterval(timeoutSeconds)
|
||||
|
||||
while totalRead < count && Date() < deadline {
|
||||
let n = result.withUnsafeMutableBufferPointer { buf in
|
||||
read(fd, buf.baseAddress!.advanced(by: totalRead), count - totalRead)
|
||||
}
|
||||
if n > 0 {
|
||||
totalRead += n
|
||||
} else if n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK) {
|
||||
// Not ready yet, brief sleep before retry.
|
||||
usleep(1000) // 1ms
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return totalRead == count ? result : nil
|
||||
}
|
||||
|
||||
/// Sets a small send buffer on a socket to make it fill quickly.
|
||||
private func setSendBufferSize(fd: Int32, size: Int32) {
|
||||
var bufSize = size
|
||||
setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &bufSize, socklen_t(MemoryLayout<Int32>.size))
|
||||
}
|
||||
|
||||
// MARK: - Test 1: Basic relay
|
||||
|
||||
@Test
|
||||
func testBasicRelay() throws {
|
||||
// Create two socketpairs:
|
||||
// pair1: (a0) --- relay ---> (b0)
|
||||
// pair2: (a1) <-- relay --- (b1)
|
||||
// The relay connects a1 <-> b0.
|
||||
// Write to a0, read from b1 (data flows: a0 → a1 → relay → b0 → b1).
|
||||
let (a0, a1) = try makeSocketPair()
|
||||
let (b0, b1) = try makeSocketPair()
|
||||
defer {
|
||||
close(a0)
|
||||
close(b1)
|
||||
}
|
||||
|
||||
let relay = BidirectionalRelay(fd1: a1, fd2: b0)
|
||||
try relay.start()
|
||||
|
||||
let testData: [UInt8] = Array("Hello, BidirectionalRelay!".utf8)
|
||||
try writeAll(fd: a0, data: testData)
|
||||
|
||||
let received = readWithTimeout(fd: b1, count: testData.count, timeoutSeconds: 2.0)
|
||||
#expect(received == testData, "Data should pass through the relay")
|
||||
|
||||
// Test the reverse direction: write to b1, read from a0.
|
||||
let reverseData: [UInt8] = Array("Reverse direction".utf8)
|
||||
try writeAll(fd: b1, data: reverseData)
|
||||
|
||||
let reverseReceived = readWithTimeout(fd: a0, count: reverseData.count, timeoutSeconds: 2.0)
|
||||
#expect(reverseReceived == reverseData, "Data should flow in reverse through the relay")
|
||||
|
||||
relay.stop()
|
||||
}
|
||||
|
||||
// MARK: - Test 2: Cross-connection head-of-line blocking
|
||||
|
||||
@Test
|
||||
func testNoCrossConnectionBlocking() throws {
|
||||
// Two relays sharing a single serial queue (simulating the old architecture).
|
||||
// One relay's destination is saturated (not drained).
|
||||
// The other relay should still work — proving per-connection isolation.
|
||||
let sharedQueue = DispatchQueue(label: "test.shared-queue")
|
||||
|
||||
// Relay 1: a0 → a1 --relay1--> b0 → b1 (b1 won't be read, causing backpressure)
|
||||
let (a0, a1) = try makeSocketPair()
|
||||
let (b0, b1) = try makeSocketPair()
|
||||
|
||||
// Relay 2: c0 → c1 --relay2--> d0 → d1 (d1 will be read normally)
|
||||
let (c0, c1) = try makeSocketPair()
|
||||
let (d0, d1) = try makeSocketPair()
|
||||
|
||||
defer {
|
||||
close(a0)
|
||||
close(b1)
|
||||
close(c0)
|
||||
close(d1)
|
||||
}
|
||||
|
||||
// Shrink send buffers to make them fill quickly.
|
||||
setSendBufferSize(fd: b0, size: 4096)
|
||||
|
||||
let relay1 = BidirectionalRelay(fd1: a1, fd2: b0, queue: sharedQueue)
|
||||
let relay2 = BidirectionalRelay(fd1: c1, fd2: d0, queue: sharedQueue)
|
||||
|
||||
try relay1.start()
|
||||
try relay2.start()
|
||||
|
||||
// Saturate relay1: write data into a0 but never read from b1.
|
||||
// This fills b0's send buffer, triggering backpressure on relay1.
|
||||
let largeData = [UInt8](repeating: 0x41, count: 65536)
|
||||
// Use non-blocking write to a0 so we don't block this test thread.
|
||||
let a0flags = fcntl(a0, F_GETFL)
|
||||
_ = fcntl(a0, F_SETFL, a0flags | O_NONBLOCK)
|
||||
_ = largeData.withUnsafeBufferPointer { buf in
|
||||
write(a0, buf.baseAddress!, buf.count)
|
||||
}
|
||||
_ = fcntl(a0, F_SETFL, a0flags) // restore
|
||||
|
||||
// Give relay1 time to process and get blocked.
|
||||
usleep(100_000) // 100ms
|
||||
|
||||
// Now test relay2: it should still work despite relay1 being backpressured.
|
||||
let testData: [UInt8] = Array("relay2 works!".utf8)
|
||||
try writeAll(fd: c0, data: testData)
|
||||
|
||||
let received = readWithTimeout(fd: d1, count: testData.count, timeoutSeconds: 2.0)
|
||||
#expect(received != nil, "Relay2 should not be blocked by Relay1's backpressure")
|
||||
if let received {
|
||||
#expect(received == testData, "Relay2 data should be correct")
|
||||
}
|
||||
|
||||
relay1.stop()
|
||||
relay2.stop()
|
||||
}
|
||||
|
||||
// MARK: - Test 3: Backpressure recovery
|
||||
|
||||
@Test
|
||||
func testBackpressureRecovery() throws {
|
||||
let (a0, a1) = try makeSocketPair()
|
||||
let (b0, b1) = try makeSocketPair()
|
||||
defer {
|
||||
close(a0)
|
||||
close(b1)
|
||||
}
|
||||
|
||||
// Shrink b0's send buffer so backpressure kicks in quickly.
|
||||
setSendBufferSize(fd: b0, size: 4096)
|
||||
|
||||
let relay = BidirectionalRelay(fd1: a1, fd2: b0)
|
||||
try relay.start()
|
||||
|
||||
// Write enough data to trigger backpressure (more than the send buffer).
|
||||
let totalBytes = 32768
|
||||
let sendData = [UInt8]((0..<totalBytes).map { UInt8($0 & 0xFF) })
|
||||
|
||||
// Write in a background thread since it may partially block.
|
||||
let writeThread = Thread {
|
||||
var offset = 0
|
||||
while offset < sendData.count {
|
||||
let n = sendData.withUnsafeBufferPointer { buf in
|
||||
write(a0, buf.baseAddress!.advanced(by: offset), min(4096, sendData.count - offset))
|
||||
}
|
||||
if n > 0 {
|
||||
offset += n
|
||||
} else if n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK) {
|
||||
usleep(1000)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
writeThread.start()
|
||||
|
||||
// Read from b1 (drain) — this should relieve backpressure.
|
||||
var received = [UInt8]()
|
||||
let readBuf = UnsafeMutableBufferPointer<UInt8>.allocate(capacity: 4096)
|
||||
defer { readBuf.deallocate() }
|
||||
|
||||
let deadline = Date().addingTimeInterval(5.0)
|
||||
let b1flags = fcntl(b1, F_GETFL)
|
||||
_ = fcntl(b1, F_SETFL, b1flags | O_NONBLOCK)
|
||||
|
||||
while received.count < totalBytes && Date() < deadline {
|
||||
let n = read(b1, readBuf.baseAddress!, readBuf.count)
|
||||
if n > 0 {
|
||||
received.append(contentsOf: UnsafeBufferPointer(start: readBuf.baseAddress!, count: n))
|
||||
} else if n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK) {
|
||||
usleep(1000)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
#expect(received.count == totalBytes, "All bytes should be received after backpressure recovery (got \(received.count)/\(totalBytes))")
|
||||
#expect(received == sendData, "Received data should match sent data")
|
||||
|
||||
relay.stop()
|
||||
}
|
||||
|
||||
// MARK: - Test 4: EOF handling
|
||||
|
||||
@Test
|
||||
func testEOFHandling() async throws {
|
||||
let (a0, a1) = try makeSocketPair()
|
||||
let (b0, b1) = try makeSocketPair()
|
||||
|
||||
let relay = BidirectionalRelay(fd1: a1, fd2: b0)
|
||||
try relay.start()
|
||||
|
||||
// Write some data, then close one end.
|
||||
let testData: [UInt8] = Array("goodbye".utf8)
|
||||
try writeAll(fd: a0, data: testData)
|
||||
close(a0)
|
||||
|
||||
// Read the data from the other end.
|
||||
let received = readWithTimeout(fd: b1, count: testData.count, timeoutSeconds: 2.0)
|
||||
#expect(received == testData, "Data should arrive before EOF")
|
||||
|
||||
// Close b1 as well so both directions see EOF.
|
||||
// (a0 closed → a1 reads EOF → source1 done;
|
||||
// b1 closed → b0 reads EOF → source2 done → relay complete)
|
||||
close(b1)
|
||||
|
||||
// The relay should detect EOF on both directions and complete.
|
||||
let completed = await withTaskGroup(of: Bool.self) { group in
|
||||
group.addTask {
|
||||
await relay.waitForCompletion()
|
||||
return true
|
||||
}
|
||||
group.addTask {
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
|
||||
return false
|
||||
}
|
||||
let result = await group.next()!
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
|
||||
#expect(completed, "Relay should complete after both sides reach EOF")
|
||||
}
|
||||
|
||||
// MARK: - Test 5: Stop while under backpressure
|
||||
|
||||
@Test
|
||||
func testStopWhileBackpressured() async throws {
|
||||
// Verify that stop() works correctly when a read source is suspended
|
||||
// due to backpressure. Previously, cancelling a suspended dispatch source
|
||||
// would never deliver the cancel handler, leaking file descriptors.
|
||||
let (a0, a1) = try makeSocketPair()
|
||||
let (b0, b1) = try makeSocketPair()
|
||||
|
||||
// Shrink b0's send buffer so backpressure kicks in quickly.
|
||||
setSendBufferSize(fd: b0, size: 4096)
|
||||
|
||||
let relay = BidirectionalRelay(fd1: a1, fd2: b0)
|
||||
try relay.start()
|
||||
|
||||
// Write enough to trigger backpressure but don't read from b1.
|
||||
let largeData = [UInt8](repeating: 0x42, count: 65536)
|
||||
let a0flags = fcntl(a0, F_GETFL)
|
||||
_ = fcntl(a0, F_SETFL, a0flags | O_NONBLOCK)
|
||||
_ = largeData.withUnsafeBufferPointer { buf in
|
||||
write(a0, buf.baseAddress!, buf.count)
|
||||
}
|
||||
|
||||
// Give relay time to enter backpressure state (readSource suspended).
|
||||
usleep(100_000) // 100ms
|
||||
|
||||
// Stop the relay while it's backpressured. This should not hang or leak.
|
||||
relay.stop()
|
||||
|
||||
// Close the external ends — the relay's fds should already be closed by stop().
|
||||
close(a0)
|
||||
close(b1)
|
||||
|
||||
// The relay should complete (cancel handlers should have fired).
|
||||
let completed = await withTaskGroup(of: Bool.self) { group in
|
||||
group.addTask {
|
||||
await relay.waitForCompletion()
|
||||
return true
|
||||
}
|
||||
group.addTask {
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
|
||||
return false
|
||||
}
|
||||
let result = await group.next()!
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
|
||||
#expect(completed, "Relay should complete after stop() even when backpressured")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
#if os(Linux)
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
#if canImport(Musl)
|
||||
import Musl
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#endif
|
||||
|
||||
@testable import ContainerizationOS
|
||||
|
||||
@Suite("Epoll tests")
|
||||
final class EpollTests {
|
||||
|
||||
@Suite("Mask option set")
|
||||
struct MaskTests {
|
||||
@Test
|
||||
func inputAndOutputAreDistinct() {
|
||||
let input = Epoll.Mask.input
|
||||
let output = Epoll.Mask.output
|
||||
#expect(input != output)
|
||||
#expect(input.isDisjoint(with: output))
|
||||
}
|
||||
|
||||
@Test
|
||||
func readyToReadMatchesInput() {
|
||||
let mask = Epoll.Mask.input
|
||||
#expect(mask.readyToRead)
|
||||
#expect(!mask.readyToWrite)
|
||||
}
|
||||
|
||||
@Test
|
||||
func readyToWriteMatchesOutput() {
|
||||
let mask = Epoll.Mask.output
|
||||
#expect(mask.readyToWrite)
|
||||
#expect(!mask.readyToRead)
|
||||
}
|
||||
|
||||
@Test
|
||||
func combinedMask() {
|
||||
let mask: Epoll.Mask = [.input, .output]
|
||||
#expect(mask.readyToRead)
|
||||
#expect(mask.readyToWrite)
|
||||
}
|
||||
|
||||
@Test
|
||||
func emptyMaskHasNoFlags() {
|
||||
let mask = Epoll.Mask(rawValue: 0)
|
||||
#expect(!mask.readyToRead)
|
||||
#expect(!mask.readyToWrite)
|
||||
#expect(!mask.isHangup)
|
||||
#expect(!mask.isRemoteHangup)
|
||||
}
|
||||
}
|
||||
|
||||
private static func makePipe() throws -> (Int32, Int32) {
|
||||
var fds: [Int32] = [0, 0]
|
||||
guard pipe(&fds) == 0 else {
|
||||
throw POSIXError.fromErrno()
|
||||
}
|
||||
return (fds[0], fds[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
func addAndDeletePipeFD() throws {
|
||||
let epoll = try Epoll()
|
||||
let (readFD, writeFD) = try Self.makePipe()
|
||||
defer {
|
||||
close(readFD)
|
||||
close(writeFD)
|
||||
}
|
||||
|
||||
try epoll.add(readFD, mask: .input)
|
||||
try epoll.delete(readFD)
|
||||
}
|
||||
|
||||
@Test
|
||||
func addInvalidFDThrows() throws {
|
||||
let epoll = try Epoll()
|
||||
#expect(throws: POSIXError.self) {
|
||||
try epoll.add(-1, mask: .input)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func deletingAlreadyClosedFDThrows() throws {
|
||||
let epoll = try Epoll()
|
||||
let (readFD, writeFD) = try Self.makePipe()
|
||||
try epoll.add(readFD, mask: .input)
|
||||
close(readFD)
|
||||
close(writeFD)
|
||||
#expect(throws: POSIXError.self) {
|
||||
try epoll.delete(readFD)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func doubleDeleteThrows() throws {
|
||||
let epoll = try Epoll()
|
||||
let (readFD, writeFD) = try Self.makePipe()
|
||||
defer {
|
||||
close(readFD)
|
||||
close(writeFD)
|
||||
}
|
||||
|
||||
try epoll.add(readFD, mask: .input)
|
||||
try epoll.delete(readFD)
|
||||
#expect(throws: POSIXError.self) {
|
||||
try epoll.delete(readFD)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func waitTimesOutWithNoEvents() throws {
|
||||
let epoll = try Epoll()
|
||||
let (readFD, writeFD) = try Self.makePipe()
|
||||
defer {
|
||||
close(readFD)
|
||||
close(writeFD)
|
||||
}
|
||||
|
||||
try epoll.add(readFD, mask: .input)
|
||||
// Timeout of 0 means return immediately.
|
||||
let events = epoll.wait(timeout: 0)
|
||||
try #require(events != nil, "wait should not return nil without shutdown")
|
||||
#expect(events!.isEmpty, "No data written, so no events expected")
|
||||
}
|
||||
|
||||
@Test
|
||||
func waitReturnsReadableEvent() throws {
|
||||
let epoll = try Epoll()
|
||||
let (readFD, writeFD) = try Self.makePipe()
|
||||
defer {
|
||||
close(readFD)
|
||||
close(writeFD)
|
||||
}
|
||||
|
||||
try epoll.add(readFD, mask: .input)
|
||||
|
||||
// Write some data to make the read end readable.
|
||||
var byte: UInt8 = 42
|
||||
let n = write(writeFD, &byte, 1)
|
||||
try #require(n == 1, "write to pipe should succeed")
|
||||
|
||||
let events = epoll.wait(maxEvents: 4, timeout: 1000)
|
||||
try #require(events != nil, "wait should not return nil without shutdown")
|
||||
try #require(!events!.isEmpty, "Should have at least one event")
|
||||
|
||||
let event = events!.first { $0.fd == readFD }
|
||||
try #require(event != nil, "Should have an event for the read fd")
|
||||
#expect(event!.mask.readyToRead)
|
||||
}
|
||||
|
||||
@Test
|
||||
func waitReportsMultipleFDs() throws {
|
||||
let epoll = try Epoll()
|
||||
|
||||
let (readFD1, writeFD1) = try Self.makePipe()
|
||||
defer {
|
||||
close(readFD1)
|
||||
close(writeFD1)
|
||||
}
|
||||
let (readFD2, writeFD2) = try Self.makePipe()
|
||||
defer {
|
||||
close(readFD2)
|
||||
close(writeFD2)
|
||||
}
|
||||
|
||||
try epoll.add(readFD1, mask: .input)
|
||||
try epoll.add(readFD2, mask: .input)
|
||||
|
||||
// Write to both pipes.
|
||||
var byte: UInt8 = 1
|
||||
_ = write(writeFD1, &byte, 1)
|
||||
_ = write(writeFD2, &byte, 1)
|
||||
|
||||
let events = epoll.wait(maxEvents: 4, timeout: 1000)
|
||||
try #require(events != nil)
|
||||
#expect(events!.count == 2, "Should report events for both fds")
|
||||
|
||||
let fds = Set(events!.map { $0.fd })
|
||||
#expect(fds.contains(readFD1))
|
||||
#expect(fds.contains(readFD2))
|
||||
}
|
||||
|
||||
@Test
|
||||
func shutdownCausesWaitToReturnNil() throws {
|
||||
let epoll = try Epoll()
|
||||
let (readFD, writeFD) = try Self.makePipe()
|
||||
defer {
|
||||
close(readFD)
|
||||
close(writeFD)
|
||||
}
|
||||
|
||||
try epoll.add(readFD, mask: .input)
|
||||
|
||||
epoll.shutdown()
|
||||
|
||||
let events = epoll.wait(timeout: 0)
|
||||
#expect(events == nil, "wait should return nil after shutdown")
|
||||
}
|
||||
|
||||
@Test
|
||||
func closingWriteEndSignalsHangup() throws {
|
||||
let epoll = try Epoll()
|
||||
let (readFD, writeFD) = try Self.makePipe()
|
||||
defer { close(readFD) }
|
||||
|
||||
try epoll.add(readFD, mask: .input)
|
||||
|
||||
// Close the write end to trigger a hangup on the read end.
|
||||
close(writeFD)
|
||||
|
||||
let events = epoll.wait(maxEvents: 4, timeout: 1000)
|
||||
try #require(events != nil)
|
||||
try #require(!events!.isEmpty, "Should have a hangup event")
|
||||
|
||||
let event = events!.first { $0.fd == readFD }
|
||||
try #require(event != nil)
|
||||
#expect(event!.mask.isHangup)
|
||||
}
|
||||
|
||||
@Test
|
||||
func deletedFDIsNotReported() throws {
|
||||
let epoll = try Epoll()
|
||||
let (readFD, writeFD) = try Self.makePipe()
|
||||
defer {
|
||||
close(readFD)
|
||||
close(writeFD)
|
||||
}
|
||||
|
||||
try epoll.add(readFD, mask: .input)
|
||||
try epoll.delete(readFD)
|
||||
|
||||
// Write data, should not produce events since we deleted the fd.
|
||||
var byte: UInt8 = 1
|
||||
_ = write(writeFD, &byte, 1)
|
||||
|
||||
let events = epoll.wait(maxEvents: 4, timeout: 100)
|
||||
try #require(events != nil)
|
||||
#expect(events!.isEmpty, "Deleted fd should produce no events")
|
||||
}
|
||||
|
||||
@Test
|
||||
func edgeTriggeredRequiresDrainBeforeRenotify() throws {
|
||||
let epoll = try Epoll()
|
||||
let (readFD, writeFD) = try Self.makePipe()
|
||||
defer {
|
||||
close(readFD)
|
||||
close(writeFD)
|
||||
}
|
||||
|
||||
try epoll.add(readFD, mask: .input)
|
||||
|
||||
// Write data to make it readable.
|
||||
var byte: UInt8 = 99
|
||||
_ = write(writeFD, &byte, 1)
|
||||
|
||||
// First wait should return the event.
|
||||
let events1 = epoll.wait(maxEvents: 4, timeout: 1000)
|
||||
try #require(events1 != nil)
|
||||
try #require(!events1!.isEmpty)
|
||||
|
||||
// Without reading the data, a second immediate wait should NOT
|
||||
// re-trigger because of edge triggered semantics.
|
||||
let events2 = epoll.wait(maxEvents: 4, timeout: 0)
|
||||
try #require(events2 != nil)
|
||||
#expect(events2!.isEmpty, "Edge triggered should not re-fire without new activity")
|
||||
|
||||
// Drain the pipe.
|
||||
var buf = [UInt8](repeating: 0, count: 16)
|
||||
_ = read(readFD, &buf, buf.count)
|
||||
|
||||
// Write new data. This is a new edge, so it should trigger again.
|
||||
_ = write(writeFD, &byte, 1)
|
||||
let events3 = epoll.wait(maxEvents: 4, timeout: 1000)
|
||||
try #require(events3 != nil)
|
||||
#expect(!events3!.isEmpty, "New write after drain should trigger event")
|
||||
}
|
||||
}
|
||||
|
||||
#endif // os(Linux)
|
||||
@@ -0,0 +1,898 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization 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 Foundation
|
||||
import SystemPackage
|
||||
import Testing
|
||||
|
||||
@testable import ContainerizationOS
|
||||
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
let os_close = Darwin.close
|
||||
#elseif canImport(Musl)
|
||||
import Musl
|
||||
let os_close = Musl.close
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
let os_close = Glibc.close
|
||||
#endif
|
||||
|
||||
struct FileDescriptorPathSecureTests {
|
||||
@Test(
|
||||
"Test creation of stub file under directory successfully created by secure mkdir",
|
||||
arguments: [
|
||||
// Case 1: Single component, no intermediates needed, default permissions
|
||||
([Entry](), FilePath("foo"), nil as FilePermissions?, false),
|
||||
|
||||
// Case 2: Single component with explicit permissions
|
||||
([Entry](), FilePath("foo"), FilePermissions(rawValue: 0o755), false),
|
||||
|
||||
// Case 3: Two components, parent exists, no intermediates
|
||||
([Entry.directory(path: "foo")], FilePath("foo/bar"), nil as FilePermissions?, false),
|
||||
|
||||
// Case 4: Two components, parent missing, makeIntermediates true
|
||||
([Entry](), FilePath("foo/bar"), nil as FilePermissions?, true),
|
||||
|
||||
// Case 5: Three components, makeIntermediates true, custom permissions
|
||||
([Entry](), FilePath("foo/bar/baz"), FilePermissions(rawValue: 0o700), true),
|
||||
|
||||
// Case 6: Replace existing file with directory (single component)
|
||||
([Entry.regular(path: "foo")], FilePath("foo"), nil as FilePermissions?, false),
|
||||
|
||||
// Case 7: Replace existing file with directory path (makeIntermediates true)
|
||||
([Entry.regular(path: "foo")], FilePath("foo/bar"), nil as FilePermissions?, true),
|
||||
|
||||
// Case 8: Replace existing directory with new directory (should be idempotent)
|
||||
([Entry.directory(path: "foo")], FilePath("foo"), nil as FilePermissions?, false),
|
||||
|
||||
// Case 9: Replace nested directory structure
|
||||
(
|
||||
[
|
||||
Entry.directory(path: "foo/bar"),
|
||||
Entry.regular(path: "foo/bar/file.txt"),
|
||||
], FilePath("foo/bar"), nil as FilePermissions?, false
|
||||
),
|
||||
|
||||
// Case 10: Replace symlink with directory
|
||||
([Entry.symlink(target: "target", source: "foo")], FilePath("foo"), nil as FilePermissions?, false),
|
||||
|
||||
// Case 11: Multi-level with some intermediates existing
|
||||
([Entry.directory(path: "foo")], FilePath("foo/bar/baz"), nil as FilePermissions?, true),
|
||||
|
||||
// Case 12: Deep nesting with makeIntermediates
|
||||
([Entry](), FilePath("a/b/c/d/e"), nil as FilePermissions?, true),
|
||||
]
|
||||
)
|
||||
func testMkdirSecureValid(entries: [Entry], relativePath: FilePath, permissions: FilePermissions?, makeIntermediates: Bool) async throws {
|
||||
let rootPath = try createTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(atPath: rootPath.string) }
|
||||
try createEntries(rootPath: rootPath, entries: entries, permissions: permissions)
|
||||
let rootFd = try FileDescriptor.open(rootPath, .readOnly, options: [.directory])
|
||||
defer { try? rootFd.close() }
|
||||
|
||||
let stubFileName = "stub.txt"
|
||||
let stubContent = Data("stub file content".utf8)
|
||||
|
||||
try FileDescriptorOps.mkdir(rootFd, relativePath, permissions: permissions, makeIntermediates: makeIntermediates) { dirFd in
|
||||
// Create a stub file in the directory using openat
|
||||
let fd = openat(
|
||||
dirFd.rawValue,
|
||||
stubFileName,
|
||||
O_WRONLY | O_CREAT | O_TRUNC,
|
||||
0o644
|
||||
)
|
||||
guard fd >= 0 else {
|
||||
throw Errno(rawValue: errno)
|
||||
}
|
||||
defer { close(fd) }
|
||||
|
||||
try stubContent.withUnsafeBytes { buffer in
|
||||
guard let baseAddress = buffer.baseAddress else { return }
|
||||
let written = write(fd, baseAddress, buffer.count)
|
||||
guard written == buffer.count else {
|
||||
throw Errno(rawValue: errno)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check stub file existence at expected location
|
||||
let expectedStubPath = rootPath.appending(relativePath.string).appending(stubFileName)
|
||||
#expect(FileManager.default.fileExists(atPath: expectedStubPath.string))
|
||||
|
||||
// Verify stub file content
|
||||
let readContent = try Data(contentsOf: URL(fileURLWithPath: expectedStubPath.string))
|
||||
#expect(readContent == stubContent)
|
||||
|
||||
// Check directory permissions if specified
|
||||
if let permissions = permissions {
|
||||
// Check each component of the path
|
||||
let components = relativePath.components
|
||||
var currentPath = ""
|
||||
for (index, component) in components.enumerated() {
|
||||
if index > 0 {
|
||||
currentPath += "/"
|
||||
}
|
||||
currentPath += component.string
|
||||
|
||||
let dirPath = rootPath.appending(currentPath)
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath: dirPath.string)
|
||||
let posixPerms = attrs[.posixPermissions] as? NSNumber
|
||||
// Mask to permission bits only (not file type bits)
|
||||
let permMask: CModeT = 0o777
|
||||
let actualPerms = CModeT(posixPerms?.uint16Value ?? 0) & permMask
|
||||
let expectedPerms = permissions.rawValue & permMask
|
||||
#expect(
|
||||
actualPerms == expectedPerms,
|
||||
"Directory '\(currentPath)' has permissions 0o\(String(actualPerms, radix: 8)) but expected 0o\(String(expectedPerms, radix: 8))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test(
|
||||
"Test mkdir error cases",
|
||||
arguments: [
|
||||
// Case 1: Path starting with ".." should be rejected
|
||||
(FilePath("../escape"), false, FileDescriptorOps.Error.invalidRelativePath),
|
||||
|
||||
// Case 2: Path with ".." in middle that would escape
|
||||
(FilePath("foo/../../escape"), false, FileDescriptorOps.Error.invalidRelativePath),
|
||||
|
||||
// Case 3: Missing intermediate without makeIntermediates should fail
|
||||
(FilePath("missing/intermediate/path"), false, FileDescriptorOps.Error.invalidPathComponent),
|
||||
|
||||
// Case 4: Multiple .. that escape
|
||||
(FilePath("a/b/../../../escape"), false, FileDescriptorOps.Error.invalidRelativePath),
|
||||
]
|
||||
)
|
||||
func testMkdirSecureInvalid(relativePath: FilePath, makeIntermediates: Bool, expectedError: FileDescriptorOps.Error) async throws {
|
||||
let rootPath = try createTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(atPath: rootPath.string) }
|
||||
|
||||
let rootFd = try FileDescriptor.open(rootPath, .readOnly, options: [.directory])
|
||||
defer { try? rootFd.close() }
|
||||
|
||||
// Attempt the operation and expect it to throw
|
||||
#expect {
|
||||
try FileDescriptorOps.mkdir(rootFd, relativePath, makeIntermediates: makeIntermediates) { _ in }
|
||||
} throws: { error in
|
||||
guard let securePathError = error as? FileDescriptorOps.Error else {
|
||||
return false
|
||||
}
|
||||
// Compare error cases
|
||||
switch (securePathError, expectedError) {
|
||||
case (.invalidRelativePath, .invalidRelativePath),
|
||||
(.invalidPathComponent, .invalidPathComponent),
|
||||
(.cannotFollowSymlink, .cannotFollowSymlink):
|
||||
return true
|
||||
case (.systemError(let op1, let err1), .systemError(let op2, let err2)):
|
||||
return op1 == op2 && err1 == err2
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test(
|
||||
"Test paths with .. that normalize to valid paths",
|
||||
arguments: [
|
||||
// Paths with .. that should normalize and succeed
|
||||
("./safe", "safe"), // Leading ./ normalizes to safe
|
||||
("./a/./b", "a/b"), // Multiple ./ normalize away
|
||||
]
|
||||
)
|
||||
func testPathsWithDotNormalization(path: String, expectedNormalized: String) async throws {
|
||||
let rootPath = try createTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(atPath: rootPath.string) }
|
||||
|
||||
let rootFd = try FileDescriptor.open(rootPath, .readOnly, options: [.directory])
|
||||
defer { try? rootFd.close() }
|
||||
|
||||
let stubFileName = "stub.txt"
|
||||
let stubContent = Data("stub file content".utf8)
|
||||
|
||||
try FileDescriptorOps.mkdir(rootFd, FilePath(path), makeIntermediates: true) { dirFd in
|
||||
// Create a stub file to verify we're in the right place
|
||||
let fd = openat(
|
||||
dirFd.rawValue,
|
||||
stubFileName,
|
||||
O_WRONLY | O_CREAT | O_TRUNC,
|
||||
0o644
|
||||
)
|
||||
guard fd >= 0 else {
|
||||
throw Errno(rawValue: errno)
|
||||
}
|
||||
defer { close(fd) }
|
||||
|
||||
try stubContent.withUnsafeBytes { buffer in
|
||||
guard let baseAddress = buffer.baseAddress else { return }
|
||||
let written = write(fd, baseAddress, buffer.count)
|
||||
guard written == buffer.count else {
|
||||
throw Errno(rawValue: errno)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify stub file exists at the normalized location
|
||||
let expectedPath =
|
||||
expectedNormalized.isEmpty
|
||||
? rootPath.appending(stubFileName)
|
||||
: rootPath.appending(expectedNormalized).appending(stubFileName)
|
||||
#expect(
|
||||
FileManager.default.fileExists(atPath: expectedPath.string),
|
||||
"Expected file at normalized path: \(expectedPath.string)")
|
||||
}
|
||||
|
||||
@Test(
|
||||
"Test paths with .. that normalize to valid paths",
|
||||
arguments: [
|
||||
// Paths with .. that should fail
|
||||
("safe/.."), // Normalizes to empty (current dir)
|
||||
("a/../b"), // Normalizes to b
|
||||
("a/b/../c"), // Normalizes to a/c
|
||||
]
|
||||
)
|
||||
func testPathsWithDotDotNormalization(path: String) async throws {
|
||||
let rootPath = try createTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(atPath: rootPath.string) }
|
||||
|
||||
let rootFd = try FileDescriptor.open(rootPath, .readOnly, options: [.directory])
|
||||
defer { try? rootFd.close() }
|
||||
|
||||
#expect(throws: FileDescriptorOps.Error.invalidRelativePath.self) {
|
||||
try FileDescriptorOps.mkdir(rootFd, FilePath(path), makeIntermediates: true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test(
|
||||
"Test paths with empty components (double slashes)",
|
||||
arguments: [
|
||||
"a//b", // Double slash in middle
|
||||
"a///b", // Triple slash
|
||||
"a//b//c", // Multiple double slashes
|
||||
]
|
||||
)
|
||||
func testPathsWithEmptyComponents(path: String) async throws {
|
||||
let rootPath = try createTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(atPath: rootPath.string) }
|
||||
|
||||
let rootFd = try FileDescriptor.open(rootPath, .readOnly, options: [.directory])
|
||||
defer { try? rootFd.close() }
|
||||
|
||||
let stubFileName = "stub.txt"
|
||||
let stubContent = Data("stub file content".utf8)
|
||||
|
||||
// Should normalize and succeed (// becomes /)
|
||||
try FileDescriptorOps.mkdir(rootFd, FilePath(path), makeIntermediates: true) { dirFd in
|
||||
let fd = openat(
|
||||
dirFd.rawValue,
|
||||
stubFileName,
|
||||
O_WRONLY | O_CREAT | O_TRUNC,
|
||||
0o644
|
||||
)
|
||||
guard fd >= 0 else {
|
||||
throw Errno(rawValue: errno)
|
||||
}
|
||||
defer { close(fd) }
|
||||
|
||||
try stubContent.withUnsafeBytes { buffer in
|
||||
guard let baseAddress = buffer.baseAddress else { return }
|
||||
let written = write(fd, baseAddress, buffer.count)
|
||||
guard written == buffer.count else {
|
||||
throw Errno(rawValue: errno)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the file exists somewhere under root (normalization should handle it)
|
||||
// The exact location depends on how FilePath normalizes empty components
|
||||
let normalizedPath = FilePath(path).lexicallyNormalized()
|
||||
let expectedPath = rootPath.appending(normalizedPath.string).appending(stubFileName)
|
||||
#expect(
|
||||
FileManager.default.fileExists(atPath: expectedPath.string),
|
||||
"Expected file at normalized path: \(expectedPath.string)")
|
||||
}
|
||||
|
||||
@Test("Test very deep nesting")
|
||||
func testDeepNesting() async throws {
|
||||
let rootPath = try createTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(atPath: rootPath.string) }
|
||||
|
||||
let rootFd = try FileDescriptor.open(rootPath, .readOnly, options: [.directory])
|
||||
defer { try? rootFd.close() }
|
||||
|
||||
// Create a 100-level deep path
|
||||
var deepPath = ""
|
||||
for i in 0..<100 {
|
||||
if i > 0 { deepPath += "/" }
|
||||
deepPath += "level\(i)"
|
||||
}
|
||||
|
||||
let stubFileName = "deep.txt"
|
||||
let stubContent = Data("deep file".utf8)
|
||||
|
||||
try FileDescriptorOps.mkdir(rootFd, FilePath(deepPath), makeIntermediates: true) { dirFd in
|
||||
let fd = openat(
|
||||
dirFd.rawValue,
|
||||
stubFileName,
|
||||
O_WRONLY | O_CREAT | O_TRUNC,
|
||||
0o644
|
||||
)
|
||||
guard fd >= 0 else {
|
||||
throw Errno(rawValue: errno)
|
||||
}
|
||||
defer { close(fd) }
|
||||
|
||||
try stubContent.withUnsafeBytes { buffer in
|
||||
guard let baseAddress = buffer.baseAddress else { return }
|
||||
let written = write(fd, baseAddress, buffer.count)
|
||||
guard written == buffer.count else {
|
||||
throw Errno(rawValue: errno)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the deep file exists
|
||||
let expectedPath = rootPath.appending(deepPath).appending(stubFileName)
|
||||
#expect(FileManager.default.fileExists(atPath: expectedPath.string))
|
||||
}
|
||||
|
||||
@Test("Test path with null byte")
|
||||
func testNullByteInPath() async throws {
|
||||
let rootPath = try createTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(atPath: rootPath.string) }
|
||||
|
||||
let rootFd = try FileDescriptor.open(rootPath, .readOnly, options: [.directory])
|
||||
defer { try? rootFd.close() }
|
||||
|
||||
// Path with null byte - FilePath may handle this differently
|
||||
// This tests that we don't crash or have unexpected behavior
|
||||
let pathWithNull = "file\u{0000}.txt"
|
||||
|
||||
// Try to create it - behavior depends on FilePath's null byte handling
|
||||
// We mainly want to ensure it doesn't bypass security checks
|
||||
do {
|
||||
try FileDescriptorOps.mkdir(rootFd, FilePath(pathWithNull), makeIntermediates: true) { _ in }
|
||||
|
||||
// If it succeeds, verify it stayed within root
|
||||
let entries = try FileManager.default.contentsOfDirectory(atPath: rootPath.string)
|
||||
for entry in entries {
|
||||
let fullPath = rootPath.appending(entry)
|
||||
let canonicalRoot = try FileDescriptorOps.getCanonicalPath(rootFd)
|
||||
let canonicalEntry = try FileDescriptor.open(fullPath, .readOnly)
|
||||
let canonicalEntryPath = try FileDescriptorOps.getCanonicalPath(canonicalEntry)
|
||||
try? canonicalEntry.close()
|
||||
|
||||
// Verify entry is under root
|
||||
#expect(
|
||||
canonicalEntryPath.string.hasPrefix(canonicalRoot.string + "/") || canonicalEntryPath.string == canonicalRoot.string,
|
||||
"Entry escaped root: \(canonicalEntryPath.string)")
|
||||
}
|
||||
} catch {
|
||||
// If it fails, that's also acceptable - just don't crash
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Remove a regular file")
|
||||
func testRemoveRegularFile() throws {
|
||||
let tempPath = try createTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(atPath: tempPath.string) }
|
||||
|
||||
let rootFd = try FileDescriptor.open(tempPath, .readOnly, options: [.directory])
|
||||
defer { try? rootFd.close() }
|
||||
|
||||
// Create a regular file
|
||||
let filePath = tempPath.appending("testfile.txt")
|
||||
_ = FileManager.default.createFile(atPath: filePath.string, contents: Data("test".utf8))
|
||||
|
||||
// Verify file exists
|
||||
#expect(FileManager.default.fileExists(atPath: filePath.string))
|
||||
|
||||
// Remove it
|
||||
try FileDescriptorOps.unlinkRecursive(rootFd, filename: FilePath.Component("testfile.txt"))
|
||||
|
||||
// Verify file is gone
|
||||
#expect(!FileManager.default.fileExists(atPath: filePath.string))
|
||||
}
|
||||
|
||||
@Test("Remove an empty directory")
|
||||
func testRemoveEmptyDirectory() throws {
|
||||
let tempPath = try createTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(atPath: tempPath.string) }
|
||||
|
||||
let rootFd = try FileDescriptor.open(tempPath, .readOnly, options: [.directory])
|
||||
defer { try? rootFd.close() }
|
||||
|
||||
// Create an empty directory
|
||||
let dirPath = tempPath.appending("emptydir")
|
||||
try FileManager.default.createDirectory(atPath: dirPath.string, withIntermediateDirectories: false)
|
||||
|
||||
// Verify directory exists
|
||||
var isDir: ObjCBool = false
|
||||
#expect(FileManager.default.fileExists(atPath: dirPath.string, isDirectory: &isDir))
|
||||
#expect(isDir.boolValue)
|
||||
|
||||
// Remove it
|
||||
try FileDescriptorOps.unlinkRecursive(rootFd, filename: FilePath.Component("emptydir"))
|
||||
|
||||
// Verify directory is gone
|
||||
#expect(!FileManager.default.fileExists(atPath: dirPath.string))
|
||||
}
|
||||
|
||||
@Test("Remove a directory with nested files and subdirectories")
|
||||
func testRemoveNestedDirectory() throws {
|
||||
let tempPath = try createTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(atPath: tempPath.string) }
|
||||
|
||||
let rootFd = try FileDescriptor.open(tempPath, .readOnly, options: [.directory])
|
||||
defer { try? rootFd.close() }
|
||||
|
||||
// Create nested structure:
|
||||
// nested/
|
||||
// file1.txt
|
||||
// subdir/
|
||||
// file2.txt
|
||||
// deepdir/
|
||||
// file3.txt
|
||||
let nestedPath = tempPath.appending("nested")
|
||||
let subdirPath = nestedPath.appending("subdir")
|
||||
let deepdirPath = subdirPath.appending("deepdir")
|
||||
|
||||
try FileManager.default.createDirectory(atPath: deepdirPath.string, withIntermediateDirectories: true)
|
||||
_ = FileManager.default.createFile(atPath: nestedPath.appending("file1.txt").string, contents: Data("1".utf8))
|
||||
_ = FileManager.default.createFile(atPath: subdirPath.appending("file2.txt").string, contents: Data("2".utf8))
|
||||
_ = FileManager.default.createFile(atPath: deepdirPath.appending("file3.txt").string, contents: Data("3".utf8))
|
||||
|
||||
// Verify structure exists
|
||||
#expect(FileManager.default.fileExists(atPath: nestedPath.string))
|
||||
#expect(FileManager.default.fileExists(atPath: subdirPath.string))
|
||||
#expect(FileManager.default.fileExists(atPath: deepdirPath.string))
|
||||
|
||||
// Remove entire tree
|
||||
try FileDescriptorOps.unlinkRecursive(rootFd, filename: FilePath.Component("nested"))
|
||||
|
||||
// Verify everything is gone
|
||||
#expect(!FileManager.default.fileExists(atPath: nestedPath.string))
|
||||
}
|
||||
|
||||
@Test("Remove non-existent file returns without error")
|
||||
func testRemoveNonExistent() throws {
|
||||
let tempPath = try createTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(atPath: tempPath.string) }
|
||||
|
||||
let rootFd = try FileDescriptor.open(tempPath, .readOnly, options: [.directory])
|
||||
defer { try? rootFd.close() }
|
||||
|
||||
// Remove non-existent file should not throw
|
||||
try FileDescriptorOps.unlinkRecursive(rootFd, filename: FilePath.Component("nonexistent.txt"))
|
||||
}
|
||||
|
||||
@Test("Remove symlink without following it")
|
||||
func testRemoveSymlink() throws {
|
||||
let tempPath = try createTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(atPath: tempPath.string) }
|
||||
|
||||
let rootFd = try FileDescriptor.open(tempPath, .readOnly, options: [.directory])
|
||||
defer { try? rootFd.close() }
|
||||
|
||||
// Create target file and symlink
|
||||
let targetPath = tempPath.appending("target.txt")
|
||||
let linkPath = tempPath.appending("link")
|
||||
_ = FileManager.default.createFile(atPath: targetPath.string, contents: Data("target".utf8))
|
||||
try FileManager.default.createSymbolicLink(atPath: linkPath.string, withDestinationPath: "target.txt")
|
||||
|
||||
// Verify both exist
|
||||
#expect(FileManager.default.fileExists(atPath: targetPath.string))
|
||||
#expect(FileManager.default.fileExists(atPath: linkPath.string))
|
||||
|
||||
// Remove symlink
|
||||
try FileDescriptorOps.unlinkRecursive(rootFd, filename: FilePath.Component("link"))
|
||||
|
||||
// Verify symlink is gone but target remains
|
||||
#expect(!FileManager.default.fileExists(atPath: linkPath.string))
|
||||
#expect(FileManager.default.fileExists(atPath: targetPath.string))
|
||||
}
|
||||
|
||||
@Test("Remove directory with mixed content (files, dirs, symlinks)")
|
||||
func testRemoveMixedDirectory() throws {
|
||||
let tempPath = try createTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(atPath: tempPath.string) }
|
||||
|
||||
let rootFd = try FileDescriptor.open(tempPath, .readOnly, options: [.directory])
|
||||
defer { try? rootFd.close() }
|
||||
|
||||
// Create mixed structure:
|
||||
// mixed/
|
||||
// file.txt
|
||||
// subdir/
|
||||
// link -> file.txt
|
||||
let mixedPath = tempPath.appending("mixed")
|
||||
let subdirPath = mixedPath.appending("subdir")
|
||||
|
||||
try FileManager.default.createDirectory(atPath: subdirPath.string, withIntermediateDirectories: true)
|
||||
_ = FileManager.default.createFile(atPath: mixedPath.appending("file.txt").string, contents: Data("test".utf8))
|
||||
try FileManager.default.createSymbolicLink(
|
||||
atPath: mixedPath.appending("link").string,
|
||||
withDestinationPath: "file.txt"
|
||||
)
|
||||
|
||||
// Verify structure exists
|
||||
#expect(FileManager.default.fileExists(atPath: mixedPath.string))
|
||||
|
||||
// Remove entire tree
|
||||
try FileDescriptorOps.unlinkRecursive(rootFd, filename: FilePath.Component("mixed"))
|
||||
|
||||
// Verify everything is gone
|
||||
#expect(!FileManager.default.fileExists(atPath: mixedPath.string))
|
||||
}
|
||||
|
||||
@Test("Guards against removing '.' component")
|
||||
func testGuardDotComponent() throws {
|
||||
let tempPath = try createTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(atPath: tempPath.string) }
|
||||
|
||||
let rootFd = try FileDescriptor.open(tempPath, .readOnly, options: [.directory])
|
||||
defer { try? rootFd.close() }
|
||||
|
||||
// Should return without error and without removing anything
|
||||
try FileDescriptorOps.unlinkRecursive(rootFd, filename: FilePath.Component("."))
|
||||
|
||||
// Verify directory still exists
|
||||
#expect(FileManager.default.fileExists(atPath: tempPath.string))
|
||||
}
|
||||
|
||||
@Test("Guards against removing '..' component")
|
||||
func testGuardDotDotComponent() throws {
|
||||
let tempPath = try createTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(atPath: tempPath.string) }
|
||||
|
||||
let rootFd = try FileDescriptor.open(tempPath, .readOnly, options: [.directory])
|
||||
defer { try? rootFd.close() }
|
||||
|
||||
// Should return without error and without removing anything
|
||||
try FileDescriptorOps.unlinkRecursive(rootFd, filename: FilePath.Component(".."))
|
||||
|
||||
// Verify directory still exists
|
||||
#expect(FileManager.default.fileExists(atPath: tempPath.string))
|
||||
}
|
||||
|
||||
@Test("Test mkdir with empty path calls completion with parent")
|
||||
func testMkdirEmptyPath() throws {
|
||||
let rootPath = try createTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(atPath: rootPath.string) }
|
||||
|
||||
let rootFd = try FileDescriptor.open(rootPath, .readOnly, options: [.directory])
|
||||
defer { try? rootFd.close() }
|
||||
|
||||
let stubFileName = "root-level-file.txt"
|
||||
let stubContent = Data("root level content".utf8)
|
||||
var completionCalled = false
|
||||
|
||||
// Call mkdir with empty path
|
||||
try FileDescriptorOps.mkdir(rootFd, FilePath(""), makeIntermediates: false) { dirFd in
|
||||
completionCalled = true
|
||||
|
||||
// Verify dirFd is the same as rootFd
|
||||
#expect(dirFd.rawValue == rootFd.rawValue, "Completion should receive the parent directory FD")
|
||||
|
||||
// Create a file in the directory to verify we got the right FD
|
||||
let fd = openat(
|
||||
dirFd.rawValue,
|
||||
stubFileName,
|
||||
O_WRONLY | O_CREAT | O_TRUNC,
|
||||
0o644
|
||||
)
|
||||
guard fd >= 0 else {
|
||||
throw Errno(rawValue: errno)
|
||||
}
|
||||
defer { close(fd) }
|
||||
|
||||
try stubContent.withUnsafeBytes { buffer in
|
||||
guard let baseAddress = buffer.baseAddress else { return }
|
||||
let written = write(fd, baseAddress, buffer.count)
|
||||
guard written == buffer.count else {
|
||||
throw Errno(rawValue: errno)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify completion was called
|
||||
#expect(completionCalled, "Completion handler should be called for empty path")
|
||||
|
||||
// Verify file was created at root level
|
||||
let expectedPath = rootPath.appending(stubFileName)
|
||||
#expect(FileManager.default.fileExists(atPath: expectedPath.string))
|
||||
|
||||
// Verify content
|
||||
let readContent = try Data(contentsOf: URL(fileURLWithPath: expectedPath.string))
|
||||
#expect(readContent == stubContent)
|
||||
}
|
||||
|
||||
private func createTempDirectory() throws -> FilePath {
|
||||
let tempURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: tempURL, withIntermediateDirectories: true)
|
||||
return FilePath(tempURL.path)
|
||||
|
||||
}
|
||||
|
||||
private func createEntries(rootPath: FilePath, entries: [Entry], permissions: FilePermissions? = nil) throws {
|
||||
for entry in entries {
|
||||
switch entry {
|
||||
case .regular(let path):
|
||||
let fullPath = rootPath.appending(path)
|
||||
// Create parent directories if needed
|
||||
let parentPath = FilePath(fullPath.string).removingLastComponent()
|
||||
if !FileManager.default.fileExists(atPath: parentPath.string) {
|
||||
try FileManager.default.createDirectory(
|
||||
atPath: parentPath.string,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: permissions.map { [.posixPermissions: $0.rawValue] }
|
||||
)
|
||||
}
|
||||
_ = FileManager.default.createFile(
|
||||
atPath: fullPath.string,
|
||||
contents: Data("test".utf8)
|
||||
)
|
||||
case .directory(let path):
|
||||
let fullPath = rootPath.appending(path)
|
||||
try FileManager.default.createDirectory(
|
||||
atPath: fullPath.string,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: permissions.map { [.posixPermissions: $0.rawValue] }
|
||||
)
|
||||
case .symlink(let target, let source):
|
||||
let sourcePath = rootPath.appending(source)
|
||||
// Create parent directories for source if needed
|
||||
let parentPath = FilePath(sourcePath.string).removingLastComponent()
|
||||
if !FileManager.default.fileExists(atPath: parentPath.string) {
|
||||
try FileManager.default.createDirectory(
|
||||
atPath: parentPath.string,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: permissions.map { [.posixPermissions: $0.rawValue] }
|
||||
)
|
||||
}
|
||||
try FileManager.default.createSymbolicLink(
|
||||
atPath: sourcePath.string,
|
||||
withDestinationPath: target
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Entry {
|
||||
case regular(path: String)
|
||||
case directory(path: String)
|
||||
case symlink(target: String, source: String)
|
||||
}
|
||||
|
||||
// MARK: - enumerate tests
|
||||
|
||||
extension FileDescriptorPathSecureTests {
|
||||
|
||||
// Collect all entries reported by enumerate, keyed by path string.
|
||||
private func collect(root: FilePath) throws -> [String: FileDescriptorOps.EntryType] {
|
||||
let rootFd = try FileDescriptor.open(root, .readOnly, options: [.directory])
|
||||
defer { try? rootFd.close() }
|
||||
var found: [String: FileDescriptorOps.EntryType] = [:]
|
||||
try FileDescriptorOps.enumerate(rootFd) { path, type, _ in
|
||||
found[path.string] = type
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
@Test func testEnumerateSecureEmptyDirectory() throws {
|
||||
let root = try createTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(atPath: root.string) }
|
||||
|
||||
let found = try collect(root: root)
|
||||
#expect(found.isEmpty)
|
||||
}
|
||||
|
||||
@Test func testEnumerateSecureFlatRegularFiles() throws {
|
||||
let root = try createTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(atPath: root.string) }
|
||||
try createEntries(
|
||||
rootPath: root,
|
||||
entries: [
|
||||
.regular(path: "a.txt"),
|
||||
.regular(path: "b.txt"),
|
||||
.regular(path: "c.txt"),
|
||||
])
|
||||
|
||||
let found = try collect(root: root)
|
||||
#expect(found.count == 3)
|
||||
#expect(found["a.txt"] == .regular)
|
||||
#expect(found["b.txt"] == .regular)
|
||||
#expect(found["c.txt"] == .regular)
|
||||
}
|
||||
|
||||
@Test func testEnumerateSecureRecursesIntoRealDirectories() throws {
|
||||
let root = try createTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(atPath: root.string) }
|
||||
try createEntries(
|
||||
rootPath: root,
|
||||
entries: [
|
||||
.directory(path: "subdir"),
|
||||
.regular(path: "subdir/file.txt"),
|
||||
])
|
||||
|
||||
let found = try collect(root: root)
|
||||
#expect(found.count == 2)
|
||||
#expect(found["subdir"] == .directory)
|
||||
#expect(found["subdir/file.txt"] == .regular)
|
||||
}
|
||||
|
||||
@Test func testEnumerateSecureReportsFileSymlink() throws {
|
||||
let root = try createTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(atPath: root.string) }
|
||||
try createEntries(
|
||||
rootPath: root,
|
||||
entries: [
|
||||
.regular(path: "target.txt"),
|
||||
.symlink(target: "target.txt", source: "link.txt"),
|
||||
])
|
||||
|
||||
let found = try collect(root: root)
|
||||
#expect(found["link.txt"] == .symlink)
|
||||
#expect(found["target.txt"] == .regular)
|
||||
}
|
||||
|
||||
@Test func testEnumerateSecureDoesNotFollowDirectorySymlink() throws {
|
||||
let root = try createTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(atPath: root.string) }
|
||||
|
||||
// Create a real directory with content alongside a symlink to it.
|
||||
try createEntries(
|
||||
rootPath: root,
|
||||
entries: [
|
||||
.directory(path: "real"),
|
||||
.regular(path: "real/inside.txt"),
|
||||
.symlink(target: "real", source: "link"),
|
||||
])
|
||||
|
||||
let found = try collect(root: root)
|
||||
// "link" is reported as a symlink, not followed — "link/inside.txt" absent.
|
||||
#expect(found["link"] == .symlink)
|
||||
#expect(found["link/inside.txt"] == nil)
|
||||
// The real directory and its content are still traversed normally.
|
||||
#expect(found["real"] == .directory)
|
||||
#expect(found["real/inside.txt"] == .regular)
|
||||
}
|
||||
|
||||
@Test func testEnumerateSecureDoesNotFollowAbsoluteDirectorySymlinkOutside() throws {
|
||||
let root = try createTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(atPath: root.string) }
|
||||
|
||||
// Create a directory entirely outside the root.
|
||||
let outside = try createTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(atPath: outside.string) }
|
||||
#expect(FileManager.default.createFile(atPath: outside.appending("secret.txt").string, contents: Data("secret".utf8)))
|
||||
|
||||
// Symlink inside root → absolute path outside root.
|
||||
try createEntries(
|
||||
rootPath: root,
|
||||
entries: [
|
||||
.symlink(target: outside.string, source: "escape")
|
||||
])
|
||||
|
||||
let found = try collect(root: root)
|
||||
// The symlink itself is reported…
|
||||
#expect(found["escape"] == .symlink)
|
||||
// …but nothing inside the outside directory is reachable.
|
||||
#expect(found["escape/secret.txt"] == nil)
|
||||
#expect(found.count == 1)
|
||||
}
|
||||
|
||||
@Test func testEnumerateSecureDoesNotFollowRelativeDirectorySymlinkOutside() throws {
|
||||
// Layout: base/root/ and base/outside/, symlink root/escape → ../outside
|
||||
let base = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).path
|
||||
let rootStr = (base as NSString).appendingPathComponent("root")
|
||||
let outsideStr = (base as NSString).appendingPathComponent("outside")
|
||||
try FileManager.default.createDirectory(atPath: rootStr, withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(atPath: outsideStr, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(atPath: base) }
|
||||
|
||||
#expect(FileManager.default.createFile(atPath: (outsideStr as NSString).appendingPathComponent("secret.txt"), contents: Data("secret".utf8)))
|
||||
try FileManager.default.createSymbolicLink(
|
||||
atPath: (rootStr as NSString).appendingPathComponent("escape"),
|
||||
withDestinationPath: "../outside"
|
||||
)
|
||||
|
||||
let rootFd = try FileDescriptor.open(FilePath(rootStr), .readOnly, options: [.directory])
|
||||
defer { try? rootFd.close() }
|
||||
var found: [String: FileDescriptorOps.EntryType] = [:]
|
||||
try FileDescriptorOps.enumerate(rootFd) { path, type, _ in found[path.string] = type }
|
||||
|
||||
#expect(found["escape"] == .symlink)
|
||||
#expect(found["escape/secret.txt"] == nil)
|
||||
#expect(found.count == 1)
|
||||
}
|
||||
|
||||
@Test func testEnumerateSecureMixedContent() throws {
|
||||
let root = try createTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(atPath: root.string) }
|
||||
try createEntries(
|
||||
rootPath: root,
|
||||
entries: [
|
||||
.regular(path: "readme.txt"),
|
||||
.directory(path: "src"),
|
||||
.regular(path: "src/main.swift"),
|
||||
.directory(path: "src/util"),
|
||||
.regular(path: "src/util/helper.swift"),
|
||||
.symlink(target: "readme.txt", source: "link.txt"),
|
||||
.symlink(target: "src", source: "src-link"),
|
||||
])
|
||||
|
||||
let found = try collect(root: root)
|
||||
#expect(found["readme.txt"] == .regular)
|
||||
#expect(found["src"] == .directory)
|
||||
#expect(found["src/main.swift"] == .regular)
|
||||
#expect(found["src/util"] == .directory)
|
||||
#expect(found["src/util/helper.swift"] == .regular)
|
||||
#expect(found["link.txt"] == .symlink)
|
||||
// Directory symlink: reported but not followed.
|
||||
#expect(found["src-link"] == .symlink)
|
||||
#expect(found["src-link/main.swift"] == nil)
|
||||
#expect(found.count == 7)
|
||||
}
|
||||
|
||||
@Test func testEnumerateSecurePreOrderDirectoryBeforeContents() throws {
|
||||
let root = try createTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(atPath: root.string) }
|
||||
try createEntries(
|
||||
rootPath: root,
|
||||
entries: [
|
||||
.directory(path: "dir"),
|
||||
.regular(path: "dir/child.txt"),
|
||||
])
|
||||
|
||||
let rootFd = try FileDescriptor.open(root, .readOnly, options: [.directory])
|
||||
defer { try? rootFd.close() }
|
||||
var order: [String] = []
|
||||
try FileDescriptorOps.enumerate(rootFd) { path, _, _ in order.append(path.string) }
|
||||
|
||||
let dirIdx = try #require(order.firstIndex(of: "dir"))
|
||||
let childIdx = try #require(order.firstIndex(of: "dir/child.txt"))
|
||||
#expect(dirIdx < childIdx, "directory must be reported before its contents")
|
||||
}
|
||||
|
||||
@Test func testEnumerateSecureParentFdCanOpenEntryWithoutFollowingSymlinks() throws {
|
||||
let root = try createTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(atPath: root.string) }
|
||||
let content = Data("hello".utf8)
|
||||
try createEntries(rootPath: root, entries: [.regular(path: "file.txt")])
|
||||
#expect(FileManager.default.createFile(atPath: root.appending("file.txt").string, contents: content))
|
||||
|
||||
let rootFd = try FileDescriptor.open(root, .readOnly, options: [.directory])
|
||||
defer { try? rootFd.close() }
|
||||
|
||||
var readContent: Data?
|
||||
try FileDescriptorOps.enumerate(rootFd) { path, type, parentFd in
|
||||
guard type == .regular, let name = path.lastComponent?.string else { return }
|
||||
// Open through the fd chain — no absolute path involved.
|
||||
let fd = openat(parentFd.rawValue, name, O_RDONLY | O_NOFOLLOW)
|
||||
guard fd >= 0 else { return }
|
||||
defer { _ = os_close(fd) }
|
||||
var buf = [UInt8](repeating: 0, count: 256)
|
||||
let n = read(fd, &buf, buf.count)
|
||||
if n > 0 { readContent = Data(buf.prefix(n)) }
|
||||
}
|
||||
|
||||
#expect(readContent == content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization 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 Foundation
|
||||
import SystemPackage
|
||||
import Testing
|
||||
|
||||
@testable import ContainerizationOS
|
||||
|
||||
struct FilePathOpsTests {
|
||||
@Test("absolutePath returns absolute inputs unchanged")
|
||||
func absolutePathPreservesAbsolutePath() {
|
||||
let absolute = FilePath("/tmp/containerization/file.tar")
|
||||
let resolved = FilePathOps.absolutePath(absolute)
|
||||
|
||||
#expect(resolved == absolute)
|
||||
}
|
||||
|
||||
@Test("absolutePath resolves relative paths against cwd and normalizes lexically")
|
||||
func absolutePathResolvesRelativePath() {
|
||||
let relative = FilePath("./images/../image.tar")
|
||||
let expected = FilePath(FileManager.default.currentDirectoryPath)
|
||||
.appending(relative.components)
|
||||
.lexicallyNormalized()
|
||||
let resolved = FilePathOps.absolutePath(relative)
|
||||
|
||||
#expect(resolved == expected)
|
||||
#expect(resolved.isAbsolute)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import ContainerizationOS
|
||||
|
||||
struct KeychainQueryTests {
|
||||
let securityDomain = "com.example.container-testing-keychain"
|
||||
let hostname = "testing-keychain.example.com"
|
||||
let username = "containerization-test"
|
||||
|
||||
let kq = KeychainQuery()
|
||||
|
||||
@Test(.enabled(if: !isCI))
|
||||
func keychainQuery() throws {
|
||||
defer { try? kq.delete(securityDomain: securityDomain, hostname: hostname) }
|
||||
|
||||
do {
|
||||
try kq.save(securityDomain: securityDomain, hostname: hostname, username: username, password: "foobar")
|
||||
#expect(try kq.exists(securityDomain: securityDomain, hostname: hostname))
|
||||
|
||||
let fetched = try kq.get(securityDomain: securityDomain, hostname: hostname)
|
||||
let result = try #require(fetched)
|
||||
#expect(result.username == username)
|
||||
#expect(result.password == "foobar")
|
||||
} catch KeychainQuery.Error.unhandledError(status: -25308) {
|
||||
// ignore errSecInteractionNotAllowed
|
||||
}
|
||||
}
|
||||
|
||||
@Test(.enabled(if: !isCI))
|
||||
func list() throws {
|
||||
let hostname1 = "testing-1-keychain.example.com"
|
||||
let hostname2 = "testing-2-keychain.example.com"
|
||||
|
||||
defer {
|
||||
try? kq.delete(securityDomain: securityDomain, hostname: hostname1)
|
||||
try? kq.delete(securityDomain: securityDomain, hostname: hostname2)
|
||||
}
|
||||
|
||||
do {
|
||||
try kq.save(securityDomain: securityDomain, hostname: hostname1, username: username, password: "foobar")
|
||||
try kq.save(securityDomain: securityDomain, hostname: hostname2, username: username, password: "foobar")
|
||||
|
||||
let entries = try kq.list(securityDomain: securityDomain)
|
||||
|
||||
// Verify that both hostnames exist
|
||||
let hostnames = entries.map { $0.hostname }
|
||||
#expect(hostnames.contains(hostname1))
|
||||
#expect(hostnames.contains(hostname2))
|
||||
|
||||
// Verify that the accounts exist
|
||||
for entry in entries {
|
||||
#expect(entry.username == username)
|
||||
}
|
||||
} catch KeychainQuery.Error.unhandledError(status: -25308) {
|
||||
// ignore errSecInteractionNotAllowed
|
||||
}
|
||||
}
|
||||
|
||||
private static var isCI: Bool {
|
||||
ProcessInfo.processInfo.environment["CI"] != nil
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,126 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 CShim
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import ContainerizationOS
|
||||
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#elseif canImport(Musl)
|
||||
import Musl
|
||||
#endif
|
||||
|
||||
@Suite("Socket SCM_RIGHTS tests")
|
||||
final class SocketTests {
|
||||
|
||||
/// Helper function to send a file descriptor via SCM_RIGHTS
|
||||
private func sendFileDescriptor(socket: Socket, fd: Int32) throws {
|
||||
var msg = msghdr()
|
||||
var iov = iovec()
|
||||
var buf: UInt8 = 0
|
||||
|
||||
iov.iov_base = withUnsafeMutablePointer(to: &buf) { UnsafeMutableRawPointer($0) }
|
||||
iov.iov_len = 1
|
||||
|
||||
msg.msg_iov = withUnsafeMutablePointer(to: &iov) { $0 }
|
||||
msg.msg_iovlen = 1
|
||||
|
||||
// Control message buffer for file descriptor
|
||||
var cmsgBuf = [UInt8](repeating: 0, count: Int(CZ_CMSG_SPACE(Int(MemoryLayout<Int32>.size))))
|
||||
|
||||
msg.msg_control = withUnsafeMutablePointer(to: &cmsgBuf[0]) { UnsafeMutableRawPointer($0) }
|
||||
msg.msg_controllen = .init(cmsgBuf.count)
|
||||
|
||||
// Set up control message
|
||||
let cmsgPtr = withUnsafeMutablePointer(to: &msg) { CZ_CMSG_FIRSTHDR($0) }
|
||||
guard let cmsg = cmsgPtr else {
|
||||
throw SocketError.invalidFileDescriptor
|
||||
}
|
||||
|
||||
cmsg.pointee.cmsg_level = SOL_SOCKET
|
||||
cmsg.pointee.cmsg_type = .init(SCM_RIGHTS)
|
||||
cmsg.pointee.cmsg_len = .init(CZ_CMSG_LEN(Int(MemoryLayout<Int32>.size)))
|
||||
|
||||
guard let dataPtr = CZ_CMSG_DATA(cmsg) else {
|
||||
throw SocketError.invalidFileDescriptor
|
||||
}
|
||||
|
||||
dataPtr.assumingMemoryBound(to: Int32.self).pointee = fd
|
||||
|
||||
let sendResult = withUnsafeMutablePointer(to: &msg) { msgPtr in
|
||||
sendmsg(socket.fileDescriptor, msgPtr, 0)
|
||||
}
|
||||
|
||||
guard sendResult >= 0 else {
|
||||
throw SocketError.withErrno("sendmsg failed", errno: errno)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func testSCMRightsFileDescriptorPassing() throws {
|
||||
// Create a socketpair for testing
|
||||
var fds: [Int32] = [0, 0]
|
||||
#if os(macOS)
|
||||
let result = socketpair(AF_UNIX, SOCK_STREAM, 0, &fds)
|
||||
#else
|
||||
let result = socketpair(AF_UNIX, Int32(SOCK_STREAM.rawValue), 0, &fds)
|
||||
#endif
|
||||
try #require(result == 0, "socketpair should succeed")
|
||||
|
||||
defer {
|
||||
close(fds[0])
|
||||
close(fds[1])
|
||||
}
|
||||
|
||||
// Use a dummy UnixType since we won't be using it for bind/connect/listen
|
||||
let socketType = try UnixType(path: "/tmp/dummy")
|
||||
let sendSocket = Socket(fd: fds[0], type: socketType, closeOnDeinit: false, connected: true)
|
||||
let recvSocket = Socket(fd: fds[1], type: socketType, closeOnDeinit: false, connected: true)
|
||||
|
||||
// Create a temporary file to send its descriptor
|
||||
let fileManager = FileManager.default
|
||||
let tempDir = fileManager.uniqueTemporaryDirectory()
|
||||
defer { try? fileManager.removeItem(at: tempDir) }
|
||||
|
||||
let testFilePath = tempDir.appending(path: "test.txt")
|
||||
let testContent = "Hello, SCM_RIGHTS!"
|
||||
try testContent.write(to: testFilePath, atomically: true, encoding: .utf8)
|
||||
|
||||
let testFileHandle = try FileHandle(forReadingFrom: testFilePath)
|
||||
defer { try? testFileHandle.close() }
|
||||
|
||||
let originalFD = testFileHandle.fileDescriptor
|
||||
|
||||
try sendFileDescriptor(socket: sendSocket, fd: originalFD)
|
||||
let receivedFd = try recvSocket.receiveFileDescriptor()
|
||||
let receivedFileHandle = FileHandle(fileDescriptor: receivedFd)
|
||||
defer { try? receivedFileHandle.close() }
|
||||
|
||||
try #require(receivedFileHandle.fileDescriptor != originalFD, "Received FD should be different")
|
||||
try #require(receivedFileHandle.fileDescriptor >= 0, "Received FD should be valid")
|
||||
|
||||
let data = try receivedFileHandle.readToEnd()
|
||||
try #require(data != nil, "Should be able to read from received FD")
|
||||
|
||||
let receivedContent = String(data: data!, encoding: .utf8)
|
||||
#expect(receivedContent == testContent, "Content should match original file")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 ContainerizationExtras
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import ContainerizationOS
|
||||
|
||||
@Suite("User/Group parse tests")
|
||||
final class UsersTests {
|
||||
struct TestCase: Sendable {
|
||||
let userString: String
|
||||
let expect: User.ExecUser
|
||||
let shouldThrow: Bool
|
||||
|
||||
init(_ userString: String, _ expect: User.ExecUser, _ shouldThrow: Bool) {
|
||||
self.userString = userString
|
||||
self.expect = expect
|
||||
self.shouldThrow = shouldThrow
|
||||
}
|
||||
}
|
||||
|
||||
static func createFile(path: URL, content: Data) throws {
|
||||
let parent = path.deletingLastPathComponent()
|
||||
let fileManager = FileManager.default
|
||||
try fileManager.createDirectory(at: parent, withIntermediateDirectories: true)
|
||||
try content.write(to: path)
|
||||
}
|
||||
|
||||
@Test
|
||||
func testExecUserOnlyPasswd() throws {
|
||||
let passwordContent = """
|
||||
root:x:0:0:root:/root:/bin/bash
|
||||
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
|
||||
bin:x:2:2:bin:/bin:/usr/sbin/nologin
|
||||
sys:x:3:3:sys:/dev:/usr/sbin/nologin
|
||||
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
|
||||
platform:x:1000:1000:Platform:/home/platform:/bin/sh
|
||||
"""
|
||||
|
||||
let fileManager = FileManager.default
|
||||
let tempDir = fileManager.uniqueTemporaryDirectory()
|
||||
defer { try? fileManager.removeItem(at: tempDir) }
|
||||
let passwdPath = tempDir.appending(path: "etc/passwd")
|
||||
try Self.createFile(path: passwdPath, content: passwordContent.data(using: .ascii)!)
|
||||
|
||||
let testCases: [TestCase] = [
|
||||
.init("root", .init(uid: 0, gid: 0, sgids: [], home: "/root", shell: "/bin/bash"), false),
|
||||
.init("0:0", .init(uid: 0, gid: 0, sgids: [], home: "/root", shell: "/bin/bash"), false),
|
||||
.init("platform", .init(uid: 1000, gid: 1000, sgids: [], home: "/home/platform", shell: "/bin/sh"), false),
|
||||
.init("65534", .init(uid: 65534, gid: 65534, sgids: [], home: "/nonexistent", shell: "/usr/sbin/nologin"), false),
|
||||
.init("should_fail", .init(uid: 456, gid: 123, sgids: [], home: "/undefined", shell: ""), true),
|
||||
.init(":nouser", .init(uid: 456, gid: 123, sgids: [], home: "/undefined", shell: ""), true),
|
||||
]
|
||||
|
||||
let groupPath = tempDir.appending(path: "etc/group")
|
||||
for testCase in testCases {
|
||||
if testCase.shouldThrow {
|
||||
#expect(throws: User.Error.self) {
|
||||
try User.getExecUser(userString: testCase.userString, passwdPath: passwdPath, groupPath: groupPath)
|
||||
}
|
||||
continue
|
||||
}
|
||||
let user = try User.getExecUser(userString: testCase.userString, passwdPath: passwdPath, groupPath: groupPath)
|
||||
#expect(testCase.expect.uid == user.uid)
|
||||
#expect(testCase.expect.gid == user.gid)
|
||||
#expect(testCase.expect.home == user.home)
|
||||
#expect(testCase.expect.sgids == user.sgids)
|
||||
#expect(testCase.expect.shell == user.shell)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func testExecUserNoPasswdFile() throws {
|
||||
#expect(throws: User.Error.self) {
|
||||
try User.getExecUser(
|
||||
userString: "root:root",
|
||||
passwdPath: URL(filePath: "/foobar-passwd"),
|
||||
groupPath: URL(filePath: "/foobar-group")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func testExecUserPasswdGroup() throws {
|
||||
let passwordContent = """
|
||||
root:x:0:0:root:/root:/bin/bash
|
||||
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
|
||||
bin:x:2:2:bin:/bin:/usr/sbin/nologin
|
||||
sys:x:3:3:sys:/dev:/usr/sbin/nologin
|
||||
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
|
||||
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
|
||||
platform:x:1000:1000:platform:/home/platform:/bin/bash
|
||||
"""
|
||||
|
||||
let groupContent = """
|
||||
root:x:0:
|
||||
daemon:x:1:
|
||||
bin:x:2:
|
||||
adm:x:4:platform
|
||||
tape:x:26:
|
||||
sudo:x:27:platform
|
||||
audio:x:29:platform
|
||||
video:x:44:platform
|
||||
nogroup:x:65534:
|
||||
platform:x:1000:
|
||||
"""
|
||||
|
||||
let fileManager = FileManager.default
|
||||
let tempDir = fileManager.uniqueTemporaryDirectory()
|
||||
defer { try? fileManager.removeItem(at: tempDir) }
|
||||
let passwdPath = tempDir.appending(path: "etc/passwd")
|
||||
let groupPath = tempDir.appending(path: "etc/group")
|
||||
try Self.createFile(path: passwdPath, content: passwordContent.data(using: .ascii)!)
|
||||
try Self.createFile(path: groupPath, content: groupContent.data(using: .ascii)!)
|
||||
|
||||
let testCases: [TestCase] = [
|
||||
.init("root:bin", .init(uid: 0, gid: 2, sgids: [2], home: "/root", shell: "/bin/bash"), false),
|
||||
.init("daemon:platform", .init(uid: 1, gid: 1000, sgids: [1000], home: "/usr/sbin", shell: "/usr/sbin/nologin"), false),
|
||||
.init("platform", .init(uid: 1000, gid: 1000, sgids: [4, 27, 29, 44], home: "/home/platform", shell: "/bin/bash"), false),
|
||||
.init("nobody", .init(uid: 65534, gid: 65534, sgids: [], home: "/nonexistent", shell: "/usr/sbin/nologin"), false),
|
||||
.init("2:1000", .init(uid: 2, gid: 1000, sgids: [1000], home: "/bin", shell: "/usr/sbin/nologin"), false),
|
||||
]
|
||||
|
||||
for testCase in testCases {
|
||||
if testCase.shouldThrow {
|
||||
#expect(throws: User.Error.self) {
|
||||
try User.getExecUser(userString: testCase.userString, passwdPath: passwdPath, groupPath: groupPath)
|
||||
}
|
||||
}
|
||||
let user = try User.getExecUser(userString: testCase.userString, passwdPath: passwdPath, groupPath: groupPath)
|
||||
#expect(testCase.expect.uid == user.uid)
|
||||
#expect(testCase.expect.gid == user.gid)
|
||||
#expect(testCase.expect.home == user.home)
|
||||
#expect(Set(testCase.expect.sgids) == Set(user.sgids))
|
||||
#expect(testCase.expect.shell == user.shell)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user