diff --git a/Sources/NIOFS/FileSystem.swift b/Sources/NIOFS/FileSystem.swift index a0663a1d85a..ef54426b037 100644 --- a/Sources/NIOFS/FileSystem.swift +++ b/Sources/NIOFS/FileSystem.swift @@ -651,6 +651,37 @@ public struct FileSystem: Sendable, FileSystemProtocol { } } + /// Returns the path of the current user's home directory. + /// + /// #### Implementation details + /// + /// This function first checks the `HOME` environment variable (and `USERPROFILE` on Windows). + /// If not set, on Darwin/Linux/Android it uses `getpwuid_r(3)` to query the password database. + /// + /// Note: `getpwuid_r` can potentially block on I/O (e.g., when using NIS or LDAP), + /// which is why this property is async when falling back to the password database. + /// + /// - Returns: The path to the current user's home directory. + public var homeDirectory: NIOFilePath { + get async throws { + if let path = Libc.homeDirectoryFromEnvironment() { + return NIOFilePath(path) + } + + #if canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Bionic) + return try await self.threadPool.runIfActive { + NIOFilePath( + try Libc.homeDirectoryFromPasswd().mapError { errno in + FileSystemError.getpwuid_r(errno: errno, location: .here()) + }.get() + ) + } + #else + throw FileSystemError.getpwuid_r(errno: .noSuchFileOrDirectory, location: .here()) + #endif + } + } + /// Returns a path to a temporary directory. /// /// #### Implementation details diff --git a/Sources/NIOFS/FileSystemError+Syscall.swift b/Sources/NIOFS/FileSystemError+Syscall.swift index 280041e5c6b..a6b5fa94835 100644 --- a/Sources/NIOFS/FileSystemError+Syscall.swift +++ b/Sources/NIOFS/FileSystemError+Syscall.swift @@ -985,6 +985,17 @@ extension FileSystemError { ) } + @_spi(Testing) + public static func getpwuid_r(errno: Errno, location: SourceLocation) -> Self { + FileSystemError( + code: .unavailable, + message: "Can't get home directory for current user.", + systemCall: "getpwuid_r", + errno: errno, + location: location + ) + } + @_spi(Testing) public static func fcopyfile( errno: Errno, diff --git a/Sources/NIOFS/FileSystemProtocol.swift b/Sources/NIOFS/FileSystemProtocol.swift index 4b98032accf..8fb4b283abe 100644 --- a/Sources/NIOFS/FileSystemProtocol.swift +++ b/Sources/NIOFS/FileSystemProtocol.swift @@ -108,6 +108,9 @@ public protocol FileSystemProtocol: Sendable { /// Returns the current working directory. var currentWorkingDirectory: NIOFilePath { get async throws } + /// Returns the current user's home directory. + var homeDirectory: NIOFilePath { get async throws } + /// Returns the path of the temporary directory. var temporaryDirectory: NIOFilePath { get async throws } diff --git a/Sources/NIOFS/Internal/System Calls/Syscall.swift b/Sources/NIOFS/Internal/System Calls/Syscall.swift index 7b672096bdb..cac003fa63e 100644 --- a/Sources/NIOFS/Internal/System Calls/Syscall.swift +++ b/Sources/NIOFS/Internal/System Calls/Syscall.swift @@ -278,6 +278,13 @@ public enum Syscall: Sendable { system_futimens(fd.rawValue, times) } } + + #if canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Bionic) + @_spi(Testing) + public static func getuid() -> uid_t { + system_getuid() + } + #endif } @_spi(Testing) @@ -437,4 +444,47 @@ public enum Libc: Sendable { libc_fts_close(pointer) } } + + @_spi(Testing) + public static func homeDirectoryFromEnvironment() -> FilePath? { + if let home = getenv("HOME"), home.pointee != 0 { + return FilePath(String(cString: home)) + } + #if os(Windows) + if let profile = getenv("USERPROFILE"), profile.pointee != 0 { + return FilePath(String(cString: profile)) + } + #endif + return nil + } + + #if canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Bionic) + @_spi(Testing) + public static func homeDirectoryFromPasswd() -> Result { + let uid = Syscall.getuid() + var pwd = passwd() + var result: UnsafeMutablePointer? = nil + + return withUnsafeTemporaryAllocation(of: CChar.self, capacity: 1024) { buffer in + let callResult = nothingOrErrno(retryOnInterrupt: true) { + libc_getpwuid_r( + uid, + &pwd, + buffer.baseAddress!, + buffer.count, + &result + ) + } + switch callResult { + case .success: + guard result != nil, let directoryPointer = pwd.pw_dir else { + return .failure(.noSuchFileOrDirectory) + } + return .success(FilePath(String(cString: directoryPointer))) + case .failure(let errno): + return .failure(errno) + } + } + } + #endif } diff --git a/Sources/NIOFS/Internal/System Calls/Syscalls.swift b/Sources/NIOFS/Internal/System Calls/Syscalls.swift index a8718760258..63621c21c20 100644 --- a/Sources/NIOFS/Internal/System Calls/Syscalls.swift +++ b/Sources/NIOFS/Internal/System Calls/Syscalls.swift @@ -479,3 +479,21 @@ internal func libc_fts_close( ) -> CInt { fts_close(fts) } + +#if canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Android) +/// getuid(2): Get user identification +internal func system_getuid() -> uid_t { + getuid() +} + +/// getpwuid_r(3): Get password file entry +internal func libc_getpwuid_r( + _ uid: uid_t, + _ pwd: UnsafeMutablePointer, + _ buffer: UnsafeMutablePointer, + _ bufferSize: Int, + _ result: UnsafeMutablePointer?> +) -> CInt { + getpwuid_r(uid, pwd, buffer, bufferSize, result) +} +#endif diff --git a/Sources/_NIOFileSystem/FileSystem.swift b/Sources/_NIOFileSystem/FileSystem.swift index 3fd16ecc379..adc6bbfd30b 100644 --- a/Sources/_NIOFileSystem/FileSystem.swift +++ b/Sources/_NIOFileSystem/FileSystem.swift @@ -651,6 +651,35 @@ public struct FileSystem: Sendable, FileSystemProtocol { } } + /// Returns the path of the current user's home directory. + /// + /// #### Implementation details + /// + /// This function first checks the `HOME` environment variable (and `USERPROFILE` on Windows). + /// If not set, on Darwin/Linux/Android it uses `getpwuid_r(3)` to query the password database. + /// + /// Note: `getpwuid_r` can potentially block on I/O (e.g., when using NIS or LDAP), + /// which is why this property is async when falling back to the password database. + /// + /// - Returns: The path to the current user's home directory. + public var homeDirectory: FilePath { + get async throws { + if let path = Libc.homeDirectoryFromEnvironment() { + return path + } + + #if canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Android) + return try await self.threadPool.runIfActive { + try Libc.homeDirectoryFromPasswd().mapError { errno in + FileSystemError.getpwuid_r(errno: errno, location: .here()) + }.get() + } + #else + throw FileSystemError.getpwuid_r(errno: .noSuchFileOrDirectory, location: .here()) + #endif + } + } + /// Returns a path to a temporary directory. /// /// #### Implementation details diff --git a/Sources/_NIOFileSystem/FileSystemError+Syscall.swift b/Sources/_NIOFileSystem/FileSystemError+Syscall.swift index 280041e5c6b..a6b5fa94835 100644 --- a/Sources/_NIOFileSystem/FileSystemError+Syscall.swift +++ b/Sources/_NIOFileSystem/FileSystemError+Syscall.swift @@ -985,6 +985,17 @@ extension FileSystemError { ) } + @_spi(Testing) + public static func getpwuid_r(errno: Errno, location: SourceLocation) -> Self { + FileSystemError( + code: .unavailable, + message: "Can't get home directory for current user.", + systemCall: "getpwuid_r", + errno: errno, + location: location + ) + } + @_spi(Testing) public static func fcopyfile( errno: Errno, diff --git a/Sources/_NIOFileSystem/FileSystemProtocol.swift b/Sources/_NIOFileSystem/FileSystemProtocol.swift index 2c7844e038e..7b3f771c2bb 100644 --- a/Sources/_NIOFileSystem/FileSystemProtocol.swift +++ b/Sources/_NIOFileSystem/FileSystemProtocol.swift @@ -108,6 +108,9 @@ public protocol FileSystemProtocol: Sendable { /// Returns the current working directory. var currentWorkingDirectory: FilePath { get async throws } + /// Returns the current user's home directory. + var homeDirectory: FilePath { get async throws } + /// Returns the path of the temporary directory. var temporaryDirectory: FilePath { get async throws } diff --git a/Sources/_NIOFileSystem/Internal/System Calls/Syscall.swift b/Sources/_NIOFileSystem/Internal/System Calls/Syscall.swift index 15205864580..5f6ed5cb7bf 100644 --- a/Sources/_NIOFileSystem/Internal/System Calls/Syscall.swift +++ b/Sources/_NIOFileSystem/Internal/System Calls/Syscall.swift @@ -278,6 +278,13 @@ public enum Syscall: Sendable { system_futimens(fd.rawValue, times) } } + + #if canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Bionic) + @_spi(Testing) + public static func getuid() -> uid_t { + system_getuid() + } + #endif } @_spi(Testing) @@ -437,4 +444,47 @@ public enum Libc: Sendable { libc_fts_close(pointer) } } + + @_spi(Testing) + public static func homeDirectoryFromEnvironment() -> FilePath? { + if let home = getenv("HOME"), home.pointee != 0 { + return FilePath(String(cString: home)) + } + #if os(Windows) + if let profile = getenv("USERPROFILE"), profile.pointee != 0 { + return FilePath(String(cString: profile)) + } + #endif + return nil + } + + #if canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Bionic) + @_spi(Testing) + public static func homeDirectoryFromPasswd() -> Result { + let uid = Syscall.getuid() + var pwd = passwd() + var result: UnsafeMutablePointer? = nil + + return withUnsafeTemporaryAllocation(of: CChar.self, capacity: 1024) { buffer in + let callResult = nothingOrErrno(retryOnInterrupt: true) { + libc_getpwuid_r( + uid, + &pwd, + buffer.baseAddress!, + buffer.count, + &result + ) + } + switch callResult { + case .success: + guard result != nil, let directoryPointer = pwd.pw_dir else { + return .failure(.noSuchFileOrDirectory) + } + return .success(FilePath(String(cString: directoryPointer))) + case .failure(let errno): + return .failure(errno) + } + } + } + #endif } diff --git a/Sources/_NIOFileSystem/Internal/System Calls/Syscalls.swift b/Sources/_NIOFileSystem/Internal/System Calls/Syscalls.swift index a8718760258..63621c21c20 100644 --- a/Sources/_NIOFileSystem/Internal/System Calls/Syscalls.swift +++ b/Sources/_NIOFileSystem/Internal/System Calls/Syscalls.swift @@ -479,3 +479,21 @@ internal func libc_fts_close( ) -> CInt { fts_close(fts) } + +#if canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Android) +/// getuid(2): Get user identification +internal func system_getuid() -> uid_t { + getuid() +} + +/// getpwuid_r(3): Get password file entry +internal func libc_getpwuid_r( + _ uid: uid_t, + _ pwd: UnsafeMutablePointer, + _ buffer: UnsafeMutablePointer, + _ bufferSize: Int, + _ result: UnsafeMutablePointer?> +) -> CInt { + getpwuid_r(uid, pwd, buffer, bufferSize, result) +} +#endif diff --git a/Tests/NIOFSIntegrationTests/FileSystemTests.swift b/Tests/NIOFSIntegrationTests/FileSystemTests.swift index b813c91bcbd..b709a57666f 100644 --- a/Tests/NIOFSIntegrationTests/FileSystemTests.swift +++ b/Tests/NIOFSIntegrationTests/FileSystemTests.swift @@ -548,6 +548,49 @@ final class FileSystemTests: XCTestCase { XCTAssert(directory.underlying.isAbsolute) } + func testHomeDirectory() async throws { + let directory = try await self.fs.homeDirectory + XCTAssert(!directory.underlying.isEmpty) + XCTAssert(directory.underlying.isAbsolute) + let info = try await self.fs.info(forFileAt: directory, infoAboutSymbolicLink: false) + XCTAssertEqual(info?.type, .directory) + } + + func testHomeDirectoryFromEnvironment() async throws { + // Should return a value when HOME is set (which it typically is) + if let path = Libc.homeDirectoryFromEnvironment() { + XCTAssert(!path.isEmpty) + XCTAssert(path.isAbsolute) + + // Verify it matches the high-level API + let fsHome = try await self.fs.homeDirectory + XCTAssertEqual(path, fsHome.underlying) + } else { + // If it returns nil, then HOME check should fail + let home = getenv("HOME") + XCTAssertTrue(home == nil || home!.pointee == 0, "Expected HOME to be unset or empty") + + #if os(Windows) + let profile = getenv("USERPROFILE") + XCTAssertTrue(profile == nil || profile!.pointee == 0, "Expected USERPROFILE to be unset or empty") + #endif + } + } + + #if canImport(Darwin) || canImport(Glibc) || canImport(Musl) || canImport(Android) + func testHomeDirectoryFromPasswd() { + // Should always succeed on Unix-like systems for the current user + let result = Libc.homeDirectoryFromPasswd() + switch result { + case .success(let path): + XCTAssert(!path.isEmpty) + XCTAssert(path.isAbsolute) + case .failure(let errno): + XCTFail("Expected success, got error: \(errno)") + } + } + #endif + func testInfo() async throws { let info = try await self.fs.info(forFileAt: .testDataReadme, infoAboutSymbolicLink: false) XCTAssertEqual(info?.type, .regular)