//===----------------------------------------------------------------------===// // // This source file is part of the SwiftNIO open source project // // Copyright (c) 2025 Apple Inc. and the SwiftNIO project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information // See CONTRIBUTORS.txt for the list of SwiftNIO project authors // // SPDX-License-Identifier: Apache-2.0 // //===----------------------------------------------------------------------===// import NIOCore @_spi(Testing) import NIOFS import NIOFoundationCompat import NIOPosix import XCTest @available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) final class FileHandleTests: XCTestCase { static let thisFile = FilePath(#filePath) static let testData = FilePath(#filePath) .removingLastComponent() // FileHandleTests.swift .appending("Test Data") .lexicallyNormalized() private static func temporaryFileName() -> FilePath { FilePath("swift-filesystem-tests-\(UInt64.random(in: .min ... .max))") } func withTemporaryFile( autoClose: Bool = true, _ execute: @Sendable (SystemFileHandle) async throws -> Void ) async throws { let path = try await FilePath("\(FileSystem.shared.temporaryDirectory)/\(Self.temporaryFileName())") defer { // Remove the file when we're done. XCTAssertNoThrow(try Libc.remove(path).get()) } try await withHandle( forFileAtPath: path, accessMode: .readWrite, options: [.create, .exclusiveCreate], permissions: .ownerReadWrite, autoClose: autoClose ) { handle in try await execute(handle) } } func withTestDataDirectory( autoClose: Bool = true, _ execute: @Sendable (SystemFileHandle) async throws -> Void ) async throws { try await self.withHandle( forFileAtPath: Self.testData, accessMode: .readOnly, options: [.directory, .nonBlocking], autoClose: autoClose ) { try await execute($0) } } private static func removeFile(atPath path: FilePath) { XCTAssertNoThrow(try Libc.remove(path).get()) } func withHandle( forFileAtPath path: FilePath, accessMode: FileDescriptor.AccessMode = .readOnly, options: FileDescriptor.OpenOptions = [], permissions: FilePermissions? = nil, autoClose: Bool = true, _ execute: @Sendable (SystemFileHandle) async throws -> Void ) async throws { let descriptor = try FileDescriptor.open( path, accessMode, options: options, permissions: permissions ) let handle = SystemFileHandle( takingOwnershipOf: descriptor, path: path, threadPool: .singleton ) do { try await execute(handle) if autoClose { try? await handle.close() } } catch let skip as XCTSkip { try? await handle.close() throw skip } catch { XCTFail("Test threw error: '\(error)'") // Always close on error. try await handle.close() } } func testInfo() async throws { try await self.withHandle(forFileAtPath: Self.thisFile) { handle in let info = try await handle.info() // It's hard to make more assertions than this... XCTAssertEqual(info.type, .regular) XCTAssertGreaterThan(info.size, 1024) } try await self.withTemporaryFile { handle in let info = try await handle.info() // It's hard to make more assertions than this... XCTAssertEqual(info.type, .regular) XCTAssertEqual(info.size, 0) } } func testExtendedAttributes() async throws { let attribute = "attribute-name" try await self.withTemporaryFile { handle in do { // We just created this but we can't assert that there won't be any attributes // (who knows what the filesystem will do?) so we'll use this number as a baseline. var originalAttributes = try await handle.attributeNames() originalAttributes.sort() // There should be no value for this attribute, yet. let value = try await handle.valueForAttribute(attribute) XCTAssertEqual(value, []) // Set a value. let someBytes = Array("hello, world".utf8) try await handle.updateValueForAttribute(someBytes, attribute: attribute) // Retrieve it again. let retrieved = try await handle.valueForAttribute(attribute) XCTAssertEqual(retrieved, someBytes) // There should be an attribute now. let attributes = try await handle.attributeNames() XCTAssert(Set(attributes).isSuperset(of: originalAttributes)) // Remove it. try await handle.removeValueForAttribute(attribute) // Should be back to the original values. var maybeOriginalAttributes = try await handle.attributeNames() maybeOriginalAttributes.sort() XCTAssertEqual(originalAttributes, maybeOriginalAttributes) } catch let error as FileSystemError where error.code == .unsupported { throw XCTSkip("Extended attributes are not supported on this platform.") } } } func testListExtendedAttributes() async throws { try await self.withTemporaryFile { handle in do { // Set some attributes. let attributeNames = Set((0..<5).map { "attr-\($0)" }) for attribute in attributeNames { try await handle.updateValueForAttribute([0, 1, 2], attribute: attribute) } // List the attributes. let attributes = try await handle.attributeNames() XCTAssert(Set(attributes).isSuperset(of: attributeNames)) } catch let error as FileSystemError where error.code == .unsupported { throw XCTSkip("Extended attributes are not supported on this platform.") } } } func testUpdatePermissions() async throws { try await self.withTemporaryFile { handle in let info = try await handle.info() // Default permissions we use for temporary files. XCTAssertEqual(info.permissions, .ownerReadWrite) try await handle.replacePermissions(.ownerReadWriteExecute) let actual = try await handle.info().permissions XCTAssertEqual(actual, .ownerReadWriteExecute) } } func testAddPermissions() async throws { try await self.withTemporaryFile { handle in let info = try await handle.info() // Default permissions we use for temporary files. XCTAssertEqual(info.permissions, .ownerReadWrite) let computed = try await handle.addPermissions(.ownerExecute) let actual = try await handle.info().permissions XCTAssertEqual(computed, actual) XCTAssertEqual(computed, .ownerReadWriteExecute) } } func testRemovePermissions() async throws { try await self.withTemporaryFile { handle in let info = try await handle.info() // Default permissions we use for temporary files. XCTAssertEqual(info.permissions, .ownerReadWrite) // Set execute so we can remove it. try await handle.replacePermissions(.ownerReadWriteExecute) // Remove owner execute. let computed = try await handle.removePermissions(.ownerExecute) let actual = try await handle.info().permissions XCTAssertEqual(computed, actual) XCTAssertEqual(computed, .ownerReadWrite) } } func testWithUnsafeDescriptor() async throws { try await self.withTemporaryFile { handle in // Check we can successfully return a value. let value = try await handle.withUnsafeDescriptor { descriptor in 42 } XCTAssertEqual(value, 42) } } func testDetach() async throws { try await self.withTemporaryFile(autoClose: false) { handle in let descriptor = try handle.detachUnsafeFileDescriptor() // We don't need this: just close it. XCTAssertNoThrow(try descriptor.close()) // Closing a detached handle is a no-op. try await handle.close() // All other methods should throw. try await Self.testAllMethodsThrowClosed(handle) } } func testClose() async throws { try await self.withHandle(forFileAtPath: Self.thisFile, autoClose: false) { handle in // Close. try await handle.close() // Closing is idempotent: this is fine. try await handle.close() // All other methods should throw. try await Self.testAllMethodsThrowClosed(handle) } } func testReadChunk() async throws { try await self.withHandle(forFileAtPath: Self.thisFile) { handle in do { // Zero offset. let bytes = try await handle.readChunk(fromAbsoluteOffset: 0, length: .bytes(80)) let line = String(buffer: bytes) XCTAssertEqual(line, "//===----------------------------------------------------------------------===//") } do { // Non-zero offset. let bytes = try await handle.readChunk(fromAbsoluteOffset: 5, length: .bytes(10)) let line = String(buffer: bytes) XCTAssertEqual(line, "----------") } do { // Length longer than file. let info = try await handle.info() let bytes = try await handle.readChunk( fromAbsoluteOffset: 0, length: .bytes(info.size + 10) ) // Bytes should not be larger than the file. XCTAssertEqual(bytes.readableBytes, Int(info.size)) } } } func testReadWholeFile() async throws { try await self.withHandle(forFileAtPath: Self.thisFile) { handle in // Check errors are thrown if we don't allow enough bytes. await XCTAssertThrowsFileSystemErrorAsync { try await handle.readToEnd(maximumSizeAllowed: .bytes(0)) } onError: { error in XCTAssertEqual(error.code, .resourceExhausted) } // Validate that we can read the whole file when at the limit. let info = try await handle.info() let contents = try await handle.readToEnd(maximumSizeAllowed: .bytes(info.size)) // Compare against the data as read by Foundation. let readByFoundation = try Data(contentsOf: URL(fileURLWithPath: Self.thisFile.string)) XCTAssertEqual( contents, ByteBuffer(data: readByFoundation), "Contents of \(Self.thisFile) differ to that read by Foundation" ) } } func testWriteAndReadUnseekableFile() async throws { let privateTempDirPath = try await FileSystem.shared.createTemporaryDirectory(template: "test-XXX") self.addTeardownBlock { try await FileSystem.shared.removeItem(at: privateTempDirPath, recursively: true) } let fifoPath = FilePath(privateTempDirPath).appending("fifo") guard mkfifo(fifoPath.string, 0o644) == 0 else { XCTFail("Error calling mkfifo.") return } try await self.withHandle(forFileAtPath: fifoPath, accessMode: .readWrite) { handle in let someBytes = ByteBuffer(repeating: 42, count: 1546) try await handle.write(contentsOf: someBytes.readableBytesView, toAbsoluteOffset: 0) let readSomeBytes = try await handle.readToEnd(maximumSizeAllowed: .bytes(1546)) XCTAssertEqual(readSomeBytes, someBytes) } } func testWriteAndReadUnseekableFileOverMaximumSizeAllowedThrowsError() async throws { let privateTempDirPath = try await FileSystem.shared.createTemporaryDirectory(template: "test-XXX") self.addTeardownBlock { try await FileSystem.shared.removeItem(at: privateTempDirPath, recursively: true) } let fifoPath = FilePath(privateTempDirPath).appending("fifo") guard mkfifo(fifoPath.string, 0o644) == 0 else { XCTFail("Error calling mkfifo.") return } try await self.withHandle(forFileAtPath: fifoPath, accessMode: .readWrite) { handle in let someBytes = [UInt8](repeating: 42, count: 10) try await handle.write(contentsOf: someBytes, toAbsoluteOffset: 0) await XCTAssertThrowsFileSystemErrorAsync { try await handle.readToEnd(maximumSizeAllowed: .bytes(9)) } onError: { error in XCTAssertEqual(error.code, .resourceExhausted) } } } func testWriteAndReadUnseekableFileWithOffsetsThrows() async throws { let privateTempDirPath = try await FileSystem.shared.createTemporaryDirectory(template: "test-XXX") self.addTeardownBlock { try await FileSystem.shared.removeItem(at: privateTempDirPath, recursively: true) } let fifoPath = FilePath(privateTempDirPath).appending("fifo") guard mkfifo(fifoPath.string, 0o644) == 0 else { XCTFail("Error calling mkfifo.") return } try await self.withHandle(forFileAtPath: fifoPath, accessMode: .readWrite) { handle in let someBytes = [UInt8](repeating: 42, count: 1546) await XCTAssertThrowsErrorAsync { try await handle.write(contentsOf: someBytes, toAbsoluteOffset: 42) XCTFail("Should have thrown") } onError: { error in let fileSystemError = error as! FileSystemError XCTAssertEqual(fileSystemError.code, .unsupported) XCTAssertEqual(fileSystemError.message, "File is unseekable.") } await XCTAssertThrowsErrorAsync { _ = try await handle.readToEnd(fromAbsoluteOffset: 42, maximumSizeAllowed: .bytes(1)) XCTFail("Should have thrown") } onError: { error in let fileSystemError = error as! FileSystemError XCTAssertEqual(fileSystemError.code, .unsupported) XCTAssertEqual(fileSystemError.message, "File is unseekable.") } } } func testReadWholeFileWithOffsets() async throws { try await self.withHandle(forFileAtPath: Self.thisFile) { handle in let info = try await handle.info() // We should be able to do a zero-length read at the end of the file with a max size // allowed of zero. let empty = try await handle.readToEnd( fromAbsoluteOffset: info.size, maximumSizeAllowed: .bytes(0) ) XCTAssertEqual(empty.readableBytes, 0) // Read the last 100 bytes. let bytes = try await handle.readToEnd( fromAbsoluteOffset: info.size - 100, maximumSizeAllowed: .bytes(100) ) // Compare against the data as read by Foundation. let readByFoundation = try Data(contentsOf: URL(fileURLWithPath: Self.thisFile.string)) let tail = readByFoundation.dropFirst(readByFoundation.count - 100) XCTAssertEqual( bytes, ByteBuffer(data: tail), "Contents of \(Self.thisFile) differ to that read by Foundation" ) } } func testReadFileAsChunks() async throws { try await self.withHandle(forFileAtPath: Self.thisFile) { handle in var bytes = ByteBuffer() for try await chunk in handle.readChunks(in: ..., chunkLength: .bytes(128)) { XCTAssertLessThanOrEqual(chunk.readableBytes, 128) bytes.writeImmutableBuffer(chunk) } var contents = try await handle.readToEnd(maximumSizeAllowed: .bytes(1024 * 1024)) XCTAssertEqual( bytes, contents, """ Read \(bytes.readableBytes) which were different to the \(contents.readableBytes) expected bytes. """ ) // Read from an offset. bytes.clear() for try await chunk in handle.readChunks(in: 100..., chunkLength: .bytes(128)) { XCTAssertLessThanOrEqual(chunk.readableBytes, 128) bytes.writeImmutableBuffer(chunk) } contents.moveReaderIndex(forwardBy: 100) XCTAssertEqual( bytes, contents, """ Read \(bytes.readableBytes) which were different to the \(contents.readableBytes) \ expected bytes. """ ) } } func testReadEmptyRange() async throws { try await self.withHandle(forFileAtPath: Self.thisFile) { handle in // No bytes should be read. for try await _ in handle.readChunks(in: 0..<0, chunkLength: .bytes(128)) { XCTFail("We shouldn't read any chunks.") } // No bytes should be read. for try await _ in handle.readChunks(in: 100..<100, chunkLength: .bytes(128)) { XCTFail("We shouldn't read any chunks.") } } } enum RangeType { case closed(ClosedRange) case partialThrough(PartialRangeThrough) case partialUpTo(PartialRangeUpTo) } static func testReadEndOffsetExceedsEOF(range: RangeType, handle: SystemFileHandle) async throws { var bytes = ByteBuffer() let fileChunks: FileChunks switch range { case .closed(let offsets): fileChunks = handle.readChunks(in: offsets, chunkLength: .bytes(128)) case .partialUpTo(let offsets): fileChunks = handle.readChunks(in: offsets, chunkLength: .bytes(128)) case .partialThrough(let offsets): fileChunks = handle.readChunks(in: offsets, chunkLength: .bytes(128)) } for try await chunk in fileChunks { XCTAssertLessThanOrEqual(chunk.readableBytes, 128) bytes.writeImmutableBuffer(chunk) } // We should read bytes only before the EOF. let contents = try await handle.readToEnd(maximumSizeAllowed: .bytes(1024 * 1024)) XCTAssertEqual(bytes, contents) } func testReadEndOffsetExceedsEOFClosedrange() async throws { try await self.withHandle(forFileAtPath: Self.thisFile) { handle in let info = try await handle.info() try await Self.testReadEndOffsetExceedsEOF( range: .closed(0...(info.size + 3)), handle: handle ) } } func testReadEndOffsetExceedsEOFPartialThrough() async throws { try await self.withHandle(forFileAtPath: Self.thisFile) { handle in let info = try await handle.info() try await Self.testReadEndOffsetExceedsEOF( range: .partialThrough(...(info.size + 3)), handle: handle ) } } func testReadEndOffsetExceedsEOFPartialUpTo() async throws { try await self.withHandle(forFileAtPath: Self.thisFile) { handle in let info = try await handle.info() try await Self.testReadEndOffsetExceedsEOF( range: .partialUpTo(..<(info.size + 3)), handle: handle ) } } func testReadRangeShorterThanChunklength() async throws { // Reading chunks of bytes from within a range that is shorter than the chunklength // and the length of the file. try await self.withHandle(forFileAtPath: Self.thisFile) { handle in var bytes = ByteBuffer() for try await chunk in handle.readChunks(in: 0...120, chunkLength: .bytes(128)) { XCTAssertEqual(chunk.readableBytes, 121) bytes.writeImmutableBuffer(chunk) } // We should only read bytes from within the range. XCTAssertEqual( bytes.readableBytes, 121, """ Read \(bytes.readableBytes) which were different to the 121 \ expected bytes. """ ) } } func testReadRangeLongerThanChunkAndNotMultipleOfChunkLength() async throws { // Reading chunks of bytes from within a range longer than the chunklength // and with size not a multiple of the chunklength. try await self.withHandle(forFileAtPath: Self.thisFile) { handle in var bytes = ByteBuffer() for try await chunk in handle.readChunks(in: 0...200, chunkLength: .bytes(128)) { XCTAssertLessThanOrEqual(chunk.readableBytes, 128) bytes.writeImmutableBuffer(chunk) } // We should only read bytes from within the range. XCTAssertEqual( bytes.readableBytes, 201, """ Read \(bytes.readableBytes) which were different to the 201 \ expected bytes. """ ) } } func testReadPartialFromRange() async throws { // Reading chunks of bytes from a PartialRangeFrom. try await self.withHandle(forFileAtPath: Self.thisFile) { handle in var bytes = ByteBuffer() for try await chunk in handle.readChunks(in: 0..., chunkLength: .bytes(128)) { XCTAssertLessThanOrEqual(chunk.readableBytes, 128) bytes.writeImmutableBuffer(chunk) } let contents = try await handle.readToEnd(maximumSizeAllowed: .bytes(1024 * 1024)) // We should read bytes until EOF. XCTAssertEqual( bytes.readableBytes, contents.readableBytes, """ Read \(bytes.readableBytes) which were different to the \(contents.readableBytes) \ expected bytes. """ ) } } func testUnboundedRange() async throws { // Reading chunks of bytes from an UnboundedRange. try await self.withHandle(forFileAtPath: Self.thisFile) { handle in var bytes = ByteBuffer() for try await chunk in handle.readChunks(in: ..., chunkLength: .bytes(128)) { XCTAssertLessThanOrEqual(chunk.readableBytes, 128) bytes.writeImmutableBuffer(chunk) } let contents = try await handle.readToEnd(maximumSizeAllowed: .bytes(1024 * 1024)) // We should read bytes until EOF. XCTAssertEqual( bytes.readableBytes, contents.readableBytes, """ Read \(bytes.readableBytes) which were different to the \(contents.readableBytes) \ expected bytes. """ ) } } func testReadPartialRange() async throws { // Reading chunks of bytes from a PartialRangeThrough with the upper bound inside the file. try await self.withHandle(forFileAtPath: Self.thisFile) { handle in var bytes = ByteBuffer() let contents = try await handle.readToEnd(maximumSizeAllowed: .bytes(1024 * 1024)) for try await chunk in handle.readChunks(in: ...200, chunkLength: .bytes(128)) { XCTAssertLessThanOrEqual(chunk.readableBytes, 128) bytes.writeImmutableBuffer(chunk) } // We should read the first bytes from the beginning of the file, until reaching the // upper bound of the range (inclusive). XCTAssertEqual(bytes, contents.getSlice(at: contents.readerIndex, length: 201)) bytes.clear() for try await chunk in handle.readChunks(in: ..<200, chunkLength: .bytes(128)) { XCTAssertLessThanOrEqual(chunk.readableBytes, 128) bytes.writeImmutableBuffer(chunk) } // We should read the first bytes from the beginning of the file, until reaching the // upper bound of the range (inclusive). XCTAssertEqual(bytes, contents.getSlice(at: contents.readerIndex, length: 200)) } } func testReadChunksOverloadAmbiguity() async throws { try await self.withHandle(forFileAtPath: Self.thisFile) { handle in // Seven possibilities for range: // 1. ... // 2. x...y // 3. x..( line: UInt = #line, _ expression: () async throws -> R ) async throws { await XCTAssertThrowsFileSystemErrorAsync { try await expression() } onError: { error in XCTAssertEqual(error.code, .closed) } }