diff --git a/Sources/ContainerizationOS/User.swift b/Sources/ContainerizationOS/User.swift index b3e8c95f..e1ab632b 100644 --- a/Sources/ContainerizationOS/User.swift +++ b/Sources/ContainerizationOS/User.swift @@ -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 /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) + } } diff --git a/Sources/Integration/ProcessTests.swift b/Sources/Integration/ProcessTests.swift index b0dd9f9b..7030cbe6 100644 --- a/Sources/Integration/ProcessTests.swift +++ b/Sources/Integration/ProcessTests.swift @@ -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. diff --git a/Tests/ContainerizationOSTests/UserTests.swift b/Tests/ContainerizationOSTests/UserTests.swift index e4ed29fc..ad95c2ab 100644 --- a/Tests/ContainerizationOSTests/UserTests.swift +++ b/Tests/ContainerizationOSTests/UserTests.swift @@ -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) } } } diff --git a/vminitd/Sources/vminitd/Server+GRPC.swift b/vminitd/Sources/vminitd/Server+GRPC.swift index b9e1968c..d052a695 100644 --- a/vminitd/Sources/vminitd/Server+GRPC.swift +++ b/vminitd/Sources/vminitd/Server+GRPC.swift @@ -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() + 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)") }