From 164ccac72f3b6008f469a47ce6c4f117b17aee0f Mon Sep 17 00:00:00 2001 From: i098 Date: Sun, 11 Oct 2026 11:44:57 +0000 Subject: [PATCH] Parse the `passwd` and `group` files as UTF-8 instead of ASCII User.parse read both files with String(contentsOf:encoding: .ascii), so a single non-ASCII byte (for example an accented GECOS name) made every exec fail with NSCocoaErrorDomain Code=259. Decode leniently instead, so legacy non-UTF-8 bytes are not fatal either. Add regression tests for both cases. --- Sources/ContainerizationOS/User.swift | 3 +- Tests/ContainerizationOSTests/UserTests.swift | 50 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/Sources/ContainerizationOS/User.swift b/Sources/ContainerizationOS/User.swift index b76098a40..dd5ecd919 100644 --- a/Sources/ContainerizationOS/User.swift +++ b/Sources/ContainerizationOS/User.swift @@ -112,7 +112,8 @@ extension User { guard fm.fileExists(atPath: file.absolutePath()) else { throw Error.missingFile(file.absolutePath()) } - let content = try String(contentsOf: file, encoding: .ascii) + // passwd and group files are not guaranteed to be valid ASCII or UTF-8 (for example GECOS names), so decode leniently. + let content = String(decoding: try Data(contentsOf: file), as: UTF8.self) let lines = content.components(separatedBy: .newlines) for line in lines { let trimmed = line.trimmingCharacters(in: .whitespaces) diff --git a/Tests/ContainerizationOSTests/UserTests.swift b/Tests/ContainerizationOSTests/UserTests.swift index cea3e8316..7c62e0f6b 100644 --- a/Tests/ContainerizationOSTests/UserTests.swift +++ b/Tests/ContainerizationOSTests/UserTests.swift @@ -150,4 +150,54 @@ final class UsersTests { #expect(testCase.expect.shell == user.shell) } } + + @Test + func testExecUserNonASCIIPasswdGroup() throws { + let passwordContent = """ + root:x:0:0:root:/root:/bin/bash + platform:x:1000:1000:José Müller:/home/platform:/bin/bash + """ + + let groupContent = """ + root:x:0: + platform:x:1000: + café:x:1001:platform + """ + + 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: Data(passwordContent.utf8)) + try Self.createFile(path: groupPath, content: Data(groupContent.utf8)) + + let user = try User.getExecUser(userString: "platform", passwdPath: passwdPath, groupPath: groupPath) + #expect(user.uid == 1000) + #expect(user.gid == 1000) + #expect(Set(user.sgids) == Set([1001])) + #expect(user.home == "/home/platform") + #expect(user.shell == "/bin/bash") + } + + @Test + func testExecUserInvalidUTF8Passwd() throws { + // A legacy Latin-1 byte (0xE9) in the GECOS field must not make the whole file unreadable. + var passwordContent = Data("root:x:0:0:root:/root:/bin/bash\nold:x:1001:1001:Jos".utf8) + passwordContent.append(0xE9) + passwordContent.append(Data(" M:/home/old:/bin/sh\n".utf8)) + + 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) + try Self.createFile(path: groupPath, content: Data("root:x:0:\n".utf8)) + + let user = try User.getExecUser(userString: "old", passwdPath: passwdPath, groupPath: groupPath) + #expect(user.uid == 1001) + #expect(user.home == "/home/old") + #expect(user.shell == "/bin/sh") + } }