285 lines
9.2 KiB
Swift
285 lines
9.2 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// https://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
//===----------------------------------------------------------------------===//
|
|
|
|
import ContainerizationError
|
|
|
|
#if canImport(FoundationEssentials)
|
|
import FoundationEssentials
|
|
#else
|
|
import Foundation
|
|
#endif
|
|
|
|
/// `User` provides utilities to ensure that a given username exists in
|
|
/// /etc/passwd (and /etc/group). Largely inspired by runc (and moby's)
|
|
/// `user` packages.
|
|
public enum User {
|
|
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
|
|
}
|
|
}
|
|
|
|
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 Error.parseError("cannot parse User from '\(rawString)'")
|
|
}
|
|
guard let uid = UInt32(args[2]) else {
|
|
throw Error.parseError("cannot parse uid from '\(args[2])'")
|
|
}
|
|
guard let gid = UInt32(args[3]) else {
|
|
throw Error.parseError("cannot parse gid from '\(args[3])'")
|
|
}
|
|
self.name = String(args[0])
|
|
self.password = String(args[1])
|
|
self.uid = uid
|
|
self.gid = gid
|
|
self.gecos = String(args[4])
|
|
self.home = String(args[5])
|
|
self.shell = String(args[6])
|
|
}
|
|
}
|
|
|
|
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 Error.parseError("cannot parse Group from '\(rawString)'")
|
|
}
|
|
guard let gid = UInt32(args[2]) else {
|
|
throw Error.parseError("cannot parse gid from '\(args[2])'")
|
|
}
|
|
self.name = String(args[0])
|
|
self.password = String(args[1])
|
|
self.gid = gid
|
|
self.users = args[3].split(separator: ",").map { String($0) }
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Private methods
|
|
|
|
extension User {
|
|
private static func parse(file: URL, handler: (_ line: String) throws -> Void) throws {
|
|
let fm = FileManager.default
|
|
guard fm.fileExists(atPath: file.absolutePath()) else {
|
|
throw Error.missingFile(file.absolutePath())
|
|
}
|
|
let content = try String(contentsOf: file, encoding: .ascii)
|
|
let lines = content.components(separatedBy: .newlines)
|
|
for line in lines {
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
guard !trimmed.isEmpty else {
|
|
continue
|
|
}
|
|
guard !trimmed.hasPrefix("#") else {
|
|
continue
|
|
}
|
|
try handler(trimmed)
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
/// 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]
|
|
}
|
|
|
|
/// 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: "")
|
|
|
|
var user = ExecUser(
|
|
uid: defaults.uid,
|
|
gid: defaults.gid,
|
|
sgids: defaults.sgids,
|
|
home: defaults.home,
|
|
shell: defaults.shell
|
|
)
|
|
|
|
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
|
|
}
|
|
} 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
|
|
}
|
|
|
|
user.uid = uidArg!
|
|
if user.uid < minID || user.uid > maxID {
|
|
throw Error.range
|
|
}
|
|
}
|
|
|
|
if !groupArg.isEmpty || !matchedUserName.isEmpty {
|
|
let groups: [Group]
|
|
do {
|
|
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 = []
|
|
}
|
|
|
|
if !groupArg.isEmpty {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
}
|