ContainerizationOS: Rework User type (#279)

This commit is contained in:
Danny Canter
2025-09-04 18:16:17 -07:00
committed by GitHub
parent 6a31184ea5
commit b422e036da
4 changed files with 287 additions and 169 deletions
+170 -117
View File
@@ -18,39 +18,52 @@ import ContainerizationError
import Foundation
/// `User` provides utilities to ensure that a given username exists in
/// /etc/passwd (and /etc/group).
/// /etc/passwd (and /etc/group). Largely inspired by runc (and moby's)
/// `user` packages.
public enum User {
private static let passwdFile = "/etc/passwd"
private static let groupFile = "/etc/group"
public static let passwdFilePath = URL(filePath: "/etc/passwd")
public static let groupFilePath = URL(filePath: "/etc/group")
private static let minID: UInt32 = 0
private static let maxID: UInt32 = 2_147_483_647
public struct ExecUser: Sendable {
public var uid: UInt32
public var gid: UInt32
public var sgids: [UInt32]
public var home: String
public var shell: String
public init(uid: UInt32, gid: UInt32, sgids: [UInt32], home: String, shell: String) {
self.uid = uid
self.gid = gid
self.sgids = sgids
self.home = home
self.shell = shell
}
}
private struct User {
let name: String
let password: String
let uid: UInt32
let gid: UInt32
let gecos: String
let home: String
let shell: String
public struct User {
public var name: String
public var password: String
public var uid: UInt32
public var gid: UInt32
public var gecos: String
public var home: String
public var shell: String
/// The argument `rawString` must follow the below format.
/// Name:Password:Uid:Gid:Gecos:Home:Shell
init(rawString: String) throws {
let args = rawString.split(separator: ":", omittingEmptySubsequences: false)
guard args.count == 7 else {
throw ContainerizationError.init(.invalidArgument, message: "Cannot parse User from '\(rawString)'")
throw Error.parseError("Cannot parse User from '\(rawString)'")
}
guard let uid = UInt32(args[2]) else {
throw ContainerizationError.init(.invalidArgument, message: "Cannot parse uid from '\(args[2])'")
throw Error.parseError("Cannot parse uid from '\(args[2])'")
}
guard let gid = UInt32(args[3]) else {
throw ContainerizationError.init(.invalidArgument, message: "Cannot parse gid from '\(args[3])'")
throw Error.parseError("Cannot parse gid from '\(args[3])'")
}
self.name = String(args[0])
self.password = String(args[1])
@@ -62,21 +75,21 @@ public enum User {
}
}
private struct Group {
let name: String
let password: String
let gid: UInt32
let users: [String]
struct Group {
var name: String
var password: String
var gid: UInt32
var users: [String]
/// The argument `rawString` must follow the below format.
/// Name:Password:Gid:user1,user2
init(rawString: String) throws {
let args = rawString.split(separator: ":", omittingEmptySubsequences: false)
guard args.count == 4 else {
throw ContainerizationError.init(.invalidArgument, message: "Cannot parse Group from '\(rawString)'")
throw Error.parseError("Cannot parse Group from '\(rawString)'")
}
guard let gid = UInt32(args[2]) else {
throw ContainerizationError.init(.invalidArgument, message: "Cannot parse gid from '\(args[2])'")
throw Error.parseError("Cannot parse gid from '\(args[2])'")
}
self.name = String(args[0])
self.password = String(args[1])
@@ -89,30 +102,10 @@ public enum User {
// MARK: Private methods
extension User {
/// Parse the contents of the passwd file
private static func parsePasswd(passwdFile: URL) throws -> [User] {
var users: [User] = []
try self.parse(file: passwdFile) { line in
let user = try User(rawString: line)
users.append(user)
}
return users
}
/// Parse the contents of the group file
private static func parseGroup(groupFile: URL) throws -> [Group] {
var groups: [Group] = []
try self.parse(file: groupFile) { line in
let group = try Group(rawString: line)
groups.append(group)
}
return groups
}
private static func parse(file: URL, handler: (_ line: String) throws -> Void) throws {
let fm = FileManager.default
guard fm.fileExists(atPath: file.absolutePath()) else {
throw ContainerizationError(.notFound, message: "File \(file.absolutePath()) does not exist")
throw Error.missingFile(file.absolutePath())
}
let content = try String(contentsOf: file, encoding: .ascii)
let lines = content.components(separatedBy: .newlines)
@@ -123,100 +116,160 @@ extension User {
try handler(line.trimmingCharacters(in: .whitespaces))
}
}
/// Parse the contents of the passwd file with a provided filter function.
static func parsePasswd(passwdFile: URL, filter: ((User) -> Bool)? = nil) throws -> [User] {
var users: [User] = []
try self.parse(file: passwdFile) { line in
let user = try User(rawString: line)
if let filter {
guard filter(user) else {
return
}
}
users.append(user)
}
return users
}
/// Parse the contents of the group file with a provided filter function.
static func parseGroup(groupFile: URL, filter: ((Group) -> Bool)? = nil) throws -> [Group] {
var groups: [Group] = []
try self.parse(file: groupFile) { line in
let group = try Group(rawString: line)
if let filter {
guard filter(group) else {
return
}
}
groups.append(group)
}
return groups
}
}
// MARK: Public methods
extension User {
public static func parseUser(root: String, userString: String) throws -> ExecUser {
let defaultUser = ExecUser(uid: 0, gid: 0, sgids: [], home: "/")
guard !userString.isEmpty else {
return defaultUser
/// Looks up uid in the password file specified by `passwdPath`.
public static func lookupUid(passwdPath: URL = Self.passwdFilePath, uid: UInt32) throws -> User {
let users = try parsePasswd(
passwdFile: passwdPath,
filter: { u in
u.uid == uid
})
if users.count == 0 {
throw Error.noPasswdEntries
}
return users[0]
}
let passwdPath = URL(filePath: root).appending(path: Self.passwdFile)
let groupPath = URL(filePath: root).appending(path: Self.groupFile)
let parts = userString.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
/// Parses a user string in any of the following formats:
/// "user, uid, user:group, uid:gid, uid:group, user:gid"
/// and returns an ExecUser type from the information.
public static func getExecUser(
userString: String,
defaults: ExecUser? = nil,
passwdPath: URL = Self.passwdFilePath,
groupPath: URL = Self.groupFilePath
) throws -> ExecUser {
let defaults = defaults ?? ExecUser(uid: 0, gid: 0, sgids: [], home: "/", shell: "")
let userArg = String(parts[0])
let userIdArg = Int(userArg)
var user = ExecUser(
uid: defaults.uid,
gid: defaults.gid,
sgids: defaults.sgids,
home: defaults.home,
shell: defaults.shell
)
guard FileManager.default.fileExists(atPath: passwdPath.absolutePath()) else {
guard let userIdArg else {
throw ContainerizationError(.internalError, message: "Cannot parse username \(userArg)")
let parts = userString.split(
separator: ":",
maxSplits: 1,
omittingEmptySubsequences: false
)
let userArg = parts.isEmpty ? "" : String(parts[0])
let groupArg = parts.count > 1 ? String(parts[1]) : ""
let uidArg = UInt32(userArg)
let notUID = uidArg == nil
let gidArg = UInt32(groupArg)
let notGID = gidArg == nil
let users: [User]
do {
users = try parsePasswd(passwdFile: passwdPath) { u in
if userArg.isEmpty {
return u.uid == user.uid
}
if !notUID {
return uidArg! == u.uid
}
return u.name == userArg
}
let uid = UInt32(userIdArg)
guard parts.count > 1 else {
return ExecUser(uid: uid, gid: uid, sgids: [], home: "/")
} catch Error.missingFile {
users = []
}
var matchedUserName = ""
if !users.isEmpty {
let matchedUser = users[0]
matchedUserName = matchedUser.name
user.uid = matchedUser.uid
user.gid = matchedUser.gid
user.home = matchedUser.home
user.shell = matchedUser.shell
} else if !userArg.isEmpty {
if notUID {
throw Error.noPasswdEntries
}
guard let gid = UInt32(String(parts[1])) else {
throw ContainerizationError(.internalError, message: "Cannot parse user group from \(userString)")
user.uid = uidArg!
if user.uid < minID || user.uid > maxID {
throw Error.range
}
return ExecUser(uid: uid, gid: gid, sgids: [], home: "/")
}
let registeredUsers = try parsePasswd(passwdFile: passwdPath)
guard registeredUsers.count > 0 else {
throw ContainerizationError(.internalError, message: "No users configured in passwd file.")
}
let matches = registeredUsers.filter { registeredUser in
// Check for a match (either uid/name) against the configured users from the passwd file.
// We have to check both the uid and the name cause we dont know the type of `userString`
registeredUser.name == userArg || registeredUser.uid == (userIdArg ?? -1)
}
guard let match = matches.first else {
// We did not find a matching uid/username in the passwd file
throw ContainerizationError(.internalError, message: "Cannot find User '\(userArg)' in passwd file.")
}
var user = ExecUser(uid: match.uid, gid: match.gid, sgids: [match.gid], home: match.home)
guard !match.name.isEmpty else {
return user
}
let matchedUser = match.name
var groupArg = ""
var groupIdArg: Int? = nil
if parts.count > 1 {
groupArg = String(parts[1])
groupIdArg = Int(groupArg)
}
let registeredGroups: [Group] = {
if !groupArg.isEmpty || !matchedUserName.isEmpty {
let groups: [Group]
do {
// Parse the <root>/etc/group file for a list of registered groups.
// If the file is missing / malformed, we bail out
return try parseGroup(groupFile: groupPath)
} catch {
return []
groups = try parseGroup(groupFile: groupPath) { g in
if groupArg.isEmpty {
return g.users.contains(matchedUserName)
}
if !notGID {
return gidArg! == g.gid
}
return g.name == groupArg
}
} catch Error.missingFile {
groups = []
}
}()
guard registeredGroups.count > 0 else {
return user
}
let matchingGroups = registeredGroups.filter { registeredGroup in
if !groupArg.isEmpty {
return registeredGroup.gid == (groupIdArg ?? -1) || registeredGroup.name == groupArg
if !groups.isEmpty {
user.gid = groups[0].gid
} else {
if notGID {
throw Error.noGroupEntries
}
user.gid = gidArg!
if user.gid < minID || user.gid > maxID {
throw Error.range
}
}
}
return registeredGroup.users.contains(matchedUser) || registeredGroup.gid == match.gid
}
guard matchingGroups.count > 0 else {
throw ContainerizationError(.internalError, message: "Cannot find Group '\(groupArg)' in groups file.")
}
// We have found a list of groups that match the group specified in the argument `userString`.
// Set the matched groups as the supplement groups for the user
if !groupArg.isEmpty {
// Reassign the user's group only we were explicitly asked for a group
user.gid = matchingGroups.first!.gid
user.sgids = matchingGroups.map { group in
group.gid
}
} else {
user.sgids.append(
contentsOf: matchingGroups.map { group in
group.gid
})
user.sgids = groups.map { $0.gid }
}
return user
}
public enum Error: Swift.Error {
case missingFile(String)
case range
case noPasswdEntries
case noGroupEntries
case parseError(String)
}
}
+68 -4
View File
@@ -233,8 +233,8 @@ extension IntegrationSuite {
let id = "test-process-user"
let bs = try await bootstrap()
let buffer = BufferWriter()
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
var buffer = BufferWriter()
var container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["/usr/bin/id"]
config.process.user = .init(uid: 1, gid: 1, additionalGids: [1])
config.process.stdout = buffer
@@ -243,18 +243,82 @@ extension IntegrationSuite {
try await container.create()
try await container.start()
let status = try await container.wait()
var status = try await container.wait()
try await container.stop()
guard status == 0 else {
throw IntegrationError.assert(msg: "process status \(status) != 0")
}
let expected = "uid=1(bin) gid=1(bin) groups=1(bin)"
var expected = "uid=1(bin) gid=1(bin) groups=1(bin)"
guard String(data: buffer.data, encoding: .utf8) == "\(expected)\n" else {
throw IntegrationError.assert(
msg: "process should have returned on stdout '\(expected)' != '\(String(data: buffer.data, encoding: .utf8)!)'")
}
buffer = BufferWriter()
container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["/usr/bin/id"]
// Try some uid that doesn't exist. This is supported.
config.process.user = .init(uid: 40000, gid: 40000)
config.process.stdout = buffer
}
try await container.create()
try await container.start()
status = try await container.wait()
try await container.stop()
guard status == 0 else {
throw IntegrationError.assert(msg: "process status \(status) != 0")
}
expected = "uid=40000 gid=40000 groups=40000"
guard String(data: buffer.data, encoding: .utf8) == "\(expected)\n" else {
throw IntegrationError.assert(
msg: "process should have returned on stdout '\(expected)' != '\(String(data: buffer.data, encoding: .utf8)!)'")
}
buffer = BufferWriter()
container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["/usr/bin/id"]
// Try some uid that doesn't exist. This is supported.
config.process.user = .init(username: "40000:40000")
config.process.stdout = buffer
}
try await container.create()
try await container.start()
status = try await container.wait()
try await container.stop()
guard status == 0 else {
throw IntegrationError.assert(msg: "process status \(status) != 0")
}
expected = "uid=40000 gid=40000 groups=40000"
guard String(data: buffer.data, encoding: .utf8) == "\(expected)\n" else {
throw IntegrationError.assert(
msg: "process should have returned on stdout '\(expected)' != '\(String(data: buffer.data, encoding: .utf8)!)'")
}
buffer = BufferWriter()
container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["/usr/bin/id"]
// Now for our final trick, try and run a username that doesn't exist.
config.process.user = .init(username: "thisdoesntexist")
config.process.stdout = buffer
}
try await container.create()
do {
try await container.start()
} catch {
return
}
throw IntegrationError.assert(msg: "container start should have failed")
}
// Ensure if we ask for a terminal we set TERM.
+33 -44
View File
@@ -14,7 +14,6 @@
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationError
import ContainerizationExtras
import Foundation
import Testing
@@ -43,7 +42,7 @@ final class UsersTests {
}
@Test
func testOnlyPasswd() throws {
func testExecUserOnlyPasswd() throws {
let passwordContent = """
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
@@ -60,55 +59,44 @@ final class UsersTests {
try Self.createFile(path: passwdPath, content: passwordContent.data(using: .ascii)!)
let testCases: [TestCase] = [
.init("root", .init(uid: 0, gid: 0, sgids: [0], home: "/root"), false),
.init("0:0", .init(uid: 0, gid: 0, sgids: [0], home: "/root"), false),
.init("platform", .init(uid: 1000, gid: 1000, sgids: [1000], home: "/home/platform"), false),
.init("65534", .init(uid: 65534, gid: 65534, sgids: [65534], home: "/nonexistent"), false),
.init("should_fail", .init(uid: 456, gid: 123, sgids: [9999], home: "/undefined"), true),
.init(":nouser", .init(uid: 456, gid: 123, sgids: [9999], home: "/undefined"), true),
.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: ContainerizationError.self) {
try User.parseUser(root: tempDir.absolutePath(), userString: testCase.userString)
#expect(throws: User.Error.self) {
try User.getExecUser(userString: testCase.userString, passwdPath: passwdPath, groupPath: groupPath)
}
continue
}
let user = try User.parseUser(root: tempDir.absolutePath(), userString: testCase.userString)
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)
}
}
@Test(arguments: [
TestCase("foobar", .init(uid: 0, gid: 0, sgids: [0], home: "/root"), true),
TestCase("101:101", .init(uid: 101, gid: 101, sgids: [], home: "/"), false),
TestCase("1025:must-fail", .init(uid: 0, gid: 0, sgids: [], home: "/"), true),
])
func testNoPasswd(testCase: TestCase) throws {
let fileManager = FileManager.default
let tempDir = fileManager.uniqueTemporaryDirectory()
defer {
try? fileManager.removeItem(at: tempDir)
}
if testCase.shouldThrow {
#expect(throws: ContainerizationError.self) {
try User.parseUser(root: tempDir.absolutePath(), userString: testCase.userString)
}
} else {
let parsed = try User.parseUser(root: tempDir.absolutePath(), userString: testCase.userString)
#expect(testCase.expect.uid == parsed.uid)
#expect(testCase.expect.gid == parsed.gid)
#expect(testCase.expect.home == parsed.home)
#expect(testCase.expect.sgids == parsed.sgids)
#expect(testCase.expect.shell == user.shell)
}
}
@Test
func testPasswdGroup() throws {
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
@@ -141,24 +129,25 @@ final class UsersTests {
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"), false),
.init("daemon:platform", .init(uid: 1, gid: 1000, sgids: [1000], home: "/usr/sbin"), false),
.init("platform", .init(uid: 1000, gid: 1000, sgids: [4, 27, 29, 44, 1000], home: "/home/platform"), false),
.init("nobody", .init(uid: 65534, gid: 65534, sgids: [65534], home: "/nonexistent"), false),
.init("2:1000", .init(uid: 2, gid: 1000, sgids: [1000], home: "/bin"), false),
.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: ContainerizationError.self) {
try User.parseUser(root: tempDir.absolutePath(), userString: testCase.userString)
#expect(throws: User.Error.self) {
try User.getExecUser(userString: testCase.userString, passwdPath: passwdPath, groupPath: groupPath)
}
}
let user = try User.parseUser(root: tempDir.absolutePath(), userString: testCase.userString)
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)
}
}
}
+16 -4
View File
@@ -962,13 +962,25 @@ extension Initd {
process.cwd = "/"
}
// Username is truthfully a Windows field, but we use this as away to pass through
// the exact string representation of a username a client may have given us.
// NOTE: The OCI runtime specs Username field is truthfully Windows exclusive, but we use this as a way
// to pass through the exact string representation of a username (or username:group, uid:group etc.) a client
// may have given us.
let username = process.user.username.isEmpty ? "\(process.user.uid):\(process.user.gid)" : process.user.username
let parsedUser = try User.parseUser(root: root.path, userString: username)
let parsedUser = try User.getExecUser(
userString: username,
passwdPath: URL(filePath: root.path).appending(path: "etc/passwd"),
groupPath: URL(filePath: root.path).appending(path: "etc/group")
)
process.user.uid = parsedUser.uid
process.user.gid = parsedUser.gid
process.user.additionalGids = parsedUser.sgids
process.user.additionalGids.append(contentsOf: parsedUser.sgids)
process.user.additionalGids.append(process.user.gid)
var seenSuppGids = Set<UInt32>()
process.user.additionalGids = process.user.additionalGids.filter {
seenSuppGids.insert($0).inserted
}
if !process.env.contains(where: { $0.hasPrefix("HOME=") }) {
process.env.append("HOME=\(parsedUser.home)")
}