Skip to content

Commit b422e03

Browse files
authored
ContainerizationOS: Rework User type (#279)
1 parent 6a31184 commit b422e03

File tree

4 files changed

+281
-163
lines changed

4 files changed

+281
-163
lines changed

Sources/ContainerizationOS/User.swift

Lines changed: 167 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -18,39 +18,52 @@ import ContainerizationError
1818
import Foundation
1919

2020
/// `User` provides utilities to ensure that a given username exists in
21-
/// /etc/passwd (and /etc/group).
21+
/// /etc/passwd (and /etc/group). Largely inspired by runc (and moby's)
22+
/// `user` packages.
2223
public enum User {
23-
private static let passwdFile = "/etc/passwd"
24-
private static let groupFile = "/etc/group"
24+
public static let passwdFilePath = URL(filePath: "/etc/passwd")
25+
public static let groupFilePath = URL(filePath: "/etc/group")
26+
27+
private static let minID: UInt32 = 0
28+
private static let maxID: UInt32 = 2_147_483_647
2529

2630
public struct ExecUser: Sendable {
2731
public var uid: UInt32
2832
public var gid: UInt32
2933
public var sgids: [UInt32]
3034
public var home: String
35+
public var shell: String
36+
37+
public init(uid: UInt32, gid: UInt32, sgids: [UInt32], home: String, shell: String) {
38+
self.uid = uid
39+
self.gid = gid
40+
self.sgids = sgids
41+
self.home = home
42+
self.shell = shell
43+
}
3144
}
3245

33-
private struct User {
34-
let name: String
35-
let password: String
36-
let uid: UInt32
37-
let gid: UInt32
38-
let gecos: String
39-
let home: String
40-
let shell: String
46+
public struct User {
47+
public var name: String
48+
public var password: String
49+
public var uid: UInt32
50+
public var gid: UInt32
51+
public var gecos: String
52+
public var home: String
53+
public var shell: String
4154

4255
/// The argument `rawString` must follow the below format.
4356
/// Name:Password:Uid:Gid:Gecos:Home:Shell
4457
init(rawString: String) throws {
4558
let args = rawString.split(separator: ":", omittingEmptySubsequences: false)
4659
guard args.count == 7 else {
47-
throw ContainerizationError.init(.invalidArgument, message: "Cannot parse User from '\(rawString)'")
60+
throw Error.parseError("Cannot parse User from '\(rawString)'")
4861
}
4962
guard let uid = UInt32(args[2]) else {
50-
throw ContainerizationError.init(.invalidArgument, message: "Cannot parse uid from '\(args[2])'")
63+
throw Error.parseError("Cannot parse uid from '\(args[2])'")
5164
}
5265
guard let gid = UInt32(args[3]) else {
53-
throw ContainerizationError.init(.invalidArgument, message: "Cannot parse gid from '\(args[3])'")
66+
throw Error.parseError("Cannot parse gid from '\(args[3])'")
5467
}
5568
self.name = String(args[0])
5669
self.password = String(args[1])
@@ -62,21 +75,21 @@ public enum User {
6275
}
6376
}
6477

65-
private struct Group {
66-
let name: String
67-
let password: String
68-
let gid: UInt32
69-
let users: [String]
78+
struct Group {
79+
var name: String
80+
var password: String
81+
var gid: UInt32
82+
var users: [String]
7083

7184
/// The argument `rawString` must follow the below format.
7285
/// Name:Password:Gid:user1,user2
7386
init(rawString: String) throws {
7487
let args = rawString.split(separator: ":", omittingEmptySubsequences: false)
7588
guard args.count == 4 else {
76-
throw ContainerizationError.init(.invalidArgument, message: "Cannot parse Group from '\(rawString)'")
89+
throw Error.parseError("Cannot parse Group from '\(rawString)'")
7790
}
7891
guard let gid = UInt32(args[2]) else {
79-
throw ContainerizationError.init(.invalidArgument, message: "Cannot parse gid from '\(args[2])'")
92+
throw Error.parseError("Cannot parse gid from '\(args[2])'")
8093
}
8194
self.name = String(args[0])
8295
self.password = String(args[1])
@@ -89,134 +102,174 @@ public enum User {
89102
// MARK: Private methods
90103

91104
extension User {
92-
/// Parse the contents of the passwd file
93-
private static func parsePasswd(passwdFile: URL) throws -> [User] {
105+
private static func parse(file: URL, handler: (_ line: String) throws -> Void) throws {
106+
let fm = FileManager.default
107+
guard fm.fileExists(atPath: file.absolutePath()) else {
108+
throw Error.missingFile(file.absolutePath())
109+
}
110+
let content = try String(contentsOf: file, encoding: .ascii)
111+
let lines = content.components(separatedBy: .newlines)
112+
for line in lines {
113+
guard !line.isEmpty else {
114+
continue
115+
}
116+
try handler(line.trimmingCharacters(in: .whitespaces))
117+
}
118+
}
119+
120+
/// Parse the contents of the passwd file with a provided filter function.
121+
static func parsePasswd(passwdFile: URL, filter: ((User) -> Bool)? = nil) throws -> [User] {
94122
var users: [User] = []
95123
try self.parse(file: passwdFile) { line in
96124
let user = try User(rawString: line)
125+
if let filter {
126+
guard filter(user) else {
127+
return
128+
}
129+
}
97130
users.append(user)
98131
}
99132
return users
100133
}
101134

102-
/// Parse the contents of the group file
103-
private static func parseGroup(groupFile: URL) throws -> [Group] {
135+
/// Parse the contents of the group file with a provided filter function.
136+
static func parseGroup(groupFile: URL, filter: ((Group) -> Bool)? = nil) throws -> [Group] {
104137
var groups: [Group] = []
105138
try self.parse(file: groupFile) { line in
106139
let group = try Group(rawString: line)
140+
if let filter {
141+
guard filter(group) else {
142+
return
143+
}
144+
}
107145
groups.append(group)
108146
}
109147
return groups
110148
}
111-
112-
private static func parse(file: URL, handler: (_ line: String) throws -> Void) throws {
113-
let fm = FileManager.default
114-
guard fm.fileExists(atPath: file.absolutePath()) else {
115-
throw ContainerizationError(.notFound, message: "File \(file.absolutePath()) does not exist")
116-
}
117-
let content = try String(contentsOf: file, encoding: .ascii)
118-
let lines = content.components(separatedBy: .newlines)
119-
for line in lines {
120-
guard !line.isEmpty else {
121-
continue
122-
}
123-
try handler(line.trimmingCharacters(in: .whitespaces))
124-
}
125-
}
126149
}
127150

128151
// MARK: Public methods
129152

130153
extension User {
131-
public static func parseUser(root: String, userString: String) throws -> ExecUser {
132-
let defaultUser = ExecUser(uid: 0, gid: 0, sgids: [], home: "/")
133-
guard !userString.isEmpty else {
134-
return defaultUser
154+
/// Looks up uid in the password file specified by `passwdPath`.
155+
public static func lookupUid(passwdPath: URL = Self.passwdFilePath, uid: UInt32) throws -> User {
156+
let users = try parsePasswd(
157+
passwdFile: passwdPath,
158+
filter: { u in
159+
u.uid == uid
160+
})
161+
if users.count == 0 {
162+
throw Error.noPasswdEntries
135163
}
164+
return users[0]
165+
}
136166

137-
let passwdPath = URL(filePath: root).appending(path: Self.passwdFile)
138-
let groupPath = URL(filePath: root).appending(path: Self.groupFile)
139-
let parts = userString.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
167+
/// Parses a user string in any of the following formats:
168+
/// "user, uid, user:group, uid:gid, uid:group, user:gid"
169+
/// and returns an ExecUser type from the information.
170+
public static func getExecUser(
171+
userString: String,
172+
defaults: ExecUser? = nil,
173+
passwdPath: URL = Self.passwdFilePath,
174+
groupPath: URL = Self.groupFilePath
175+
) throws -> ExecUser {
176+
let defaults = defaults ?? ExecUser(uid: 0, gid: 0, sgids: [], home: "/", shell: "")
140177

141-
let userArg = String(parts[0])
142-
let userIdArg = Int(userArg)
178+
var user = ExecUser(
179+
uid: defaults.uid,
180+
gid: defaults.gid,
181+
sgids: defaults.sgids,
182+
home: defaults.home,
183+
shell: defaults.shell
184+
)
143185

144-
guard FileManager.default.fileExists(atPath: passwdPath.absolutePath()) else {
145-
guard let userIdArg else {
146-
throw ContainerizationError(.internalError, message: "Cannot parse username \(userArg)")
147-
}
148-
let uid = UInt32(userIdArg)
149-
guard parts.count > 1 else {
150-
return ExecUser(uid: uid, gid: uid, sgids: [], home: "/")
151-
}
152-
guard let gid = UInt32(String(parts[1])) else {
153-
throw ContainerizationError(.internalError, message: "Cannot parse user group from \(userString)")
154-
}
155-
return ExecUser(uid: uid, gid: gid, sgids: [], home: "/")
156-
}
186+
let parts = userString.split(
187+
separator: ":",
188+
maxSplits: 1,
189+
omittingEmptySubsequences: false
190+
)
191+
let userArg = parts.isEmpty ? "" : String(parts[0])
192+
let groupArg = parts.count > 1 ? String(parts[1]) : ""
157193

158-
let registeredUsers = try parsePasswd(passwdFile: passwdPath)
159-
guard registeredUsers.count > 0 else {
160-
throw ContainerizationError(.internalError, message: "No users configured in passwd file.")
161-
}
162-
let matches = registeredUsers.filter { registeredUser in
163-
// Check for a match (either uid/name) against the configured users from the passwd file.
164-
// We have to check both the uid and the name cause we dont know the type of `userString`
165-
registeredUser.name == userArg || registeredUser.uid == (userIdArg ?? -1)
166-
}
167-
guard let match = matches.first else {
168-
// We did not find a matching uid/username in the passwd file
169-
throw ContainerizationError(.internalError, message: "Cannot find User '\(userArg)' in passwd file.")
194+
let uidArg = UInt32(userArg)
195+
let notUID = uidArg == nil
196+
let gidArg = UInt32(groupArg)
197+
let notGID = gidArg == nil
198+
199+
let users: [User]
200+
do {
201+
users = try parsePasswd(passwdFile: passwdPath) { u in
202+
if userArg.isEmpty {
203+
return u.uid == user.uid
204+
}
205+
if !notUID {
206+
return uidArg! == u.uid
207+
}
208+
return u.name == userArg
209+
}
210+
} catch Error.missingFile {
211+
users = []
170212
}
171213

172-
var user = ExecUser(uid: match.uid, gid: match.gid, sgids: [match.gid], home: match.home)
214+
var matchedUserName = ""
215+
if !users.isEmpty {
216+
let matchedUser = users[0]
217+
matchedUserName = matchedUser.name
218+
user.uid = matchedUser.uid
219+
user.gid = matchedUser.gid
220+
user.home = matchedUser.home
221+
user.shell = matchedUser.shell
222+
} else if !userArg.isEmpty {
223+
if notUID {
224+
throw Error.noPasswdEntries
225+
}
173226

174-
guard !match.name.isEmpty else {
175-
return user
176-
}
177-
let matchedUser = match.name
178-
var groupArg = ""
179-
var groupIdArg: Int? = nil
180-
if parts.count > 1 {
181-
groupArg = String(parts[1])
182-
groupIdArg = Int(groupArg)
227+
user.uid = uidArg!
228+
if user.uid < minID || user.uid > maxID {
229+
throw Error.range
230+
}
183231
}
184232

185-
let registeredGroups: [Group] = {
233+
if !groupArg.isEmpty || !matchedUserName.isEmpty {
234+
let groups: [Group]
186235
do {
187-
// Parse the <root>/etc/group file for a list of registered groups.
188-
// If the file is missing / malformed, we bail out
189-
return try parseGroup(groupFile: groupPath)
190-
} catch {
191-
return []
236+
groups = try parseGroup(groupFile: groupPath) { g in
237+
if groupArg.isEmpty {
238+
return g.users.contains(matchedUserName)
239+
}
240+
if !notGID {
241+
return gidArg! == g.gid
242+
}
243+
return g.name == groupArg
244+
}
245+
} catch Error.missingFile {
246+
groups = []
192247
}
193-
}()
194-
guard registeredGroups.count > 0 else {
195-
return user
196-
}
197-
let matchingGroups = registeredGroups.filter { registeredGroup in
248+
198249
if !groupArg.isEmpty {
199-
return registeredGroup.gid == (groupIdArg ?? -1) || registeredGroup.name == groupArg
200-
}
201-
return registeredGroup.users.contains(matchedUser) || registeredGroup.gid == match.gid
202-
}
203-
guard matchingGroups.count > 0 else {
204-
throw ContainerizationError(.internalError, message: "Cannot find Group '\(groupArg)' in groups file.")
205-
}
206-
// We have found a list of groups that match the group specified in the argument `userString`.
207-
// Set the matched groups as the supplement groups for the user
208-
if !groupArg.isEmpty {
209-
// Reassign the user's group only we were explicitly asked for a group
210-
user.gid = matchingGroups.first!.gid
211-
user.sgids = matchingGroups.map { group in
212-
group.gid
250+
if !groups.isEmpty {
251+
user.gid = groups[0].gid
252+
} else {
253+
if notGID {
254+
throw Error.noGroupEntries
255+
}
256+
257+
user.gid = gidArg!
258+
if user.gid < minID || user.gid > maxID {
259+
throw Error.range
260+
}
261+
}
213262
}
214-
} else {
215-
user.sgids.append(
216-
contentsOf: matchingGroups.map { group in
217-
group.gid
218-
})
263+
user.sgids = groups.map { $0.gid }
219264
}
220265
return user
221266
}
267+
268+
public enum Error: Swift.Error {
269+
case missingFile(String)
270+
case range
271+
case noPasswdEntries
272+
case noGroupEntries
273+
case parseError(String)
274+
}
222275
}

0 commit comments

Comments
 (0)