internal/resources/utils/user/user.go (176 lines of code) (raw):
// Licensed to Elasticsearch B.V. under one or more contributor
// license agreements. See the NOTICE file distributed with
// this work for additional information regarding copyright
// ownership. Elasticsearch B.V. licenses this file to you 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
//
// http://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.
package user
import (
"bufio"
"bytes"
"errors"
"io"
"os"
"strconv"
"strings"
)
// The util implementation is based on Go "os/user" native pkg
// User represents a user account.
type User struct {
// Uid is the user ID.
// On POSIX systems, this is a decimal number representing the uid.
// On Windows, this is a security identifier (SID) in a string format.
// On Plan 9, this is the contents of /dev/user.
Uid string
// Gid is the primary group ID.
// On POSIX systems, this is a decimal number representing the gid.
// On Windows, this is a SID in a string format.
// On Plan 9, this is the contents of /dev/user.
Gid string
// Username is the login name.
Username string
// Name is the user's real or display name.
// It might be blank.
// On POSIX systems, this is the first (or only) entry in the GECOS field
// list.
// On Windows, this is the user's display name.
// On Plan 9, this is the contents of /dev/user.
Name string
// HomeDir is the path to the user's home directory (if they have one).
HomeDir string
}
// Group represents a grouping of users.
//
// On POSIX systems Gid contains a decimal number representing the group ID.
type Group struct {
Gid string // group ID
Name string // group name
}
// lineFunc returns a value, an error, or (nil, nil) to skip the row.
type lineFunc func(line []byte) (v any, err error)
type OSUser interface {
GetUserNameFromID(uid string, userFilePath string) (string, error)
GetGroupNameFromID(gid string, groupFilePath string) (string, error)
}
type OSUserUtil struct{}
func NewOSUserUtil() OSUser {
return &OSUserUtil{}
}
func (*OSUserUtil) GetUserNameFromID(uid string, userFilePath string) (string, error) {
usr, err := lookupUserId(uid, userFilePath)
if err != nil || usr == nil {
return "", err
}
return usr.Username, nil
}
func (*OSUserUtil) GetGroupNameFromID(gid string, groupFilePath string) (string, error) {
group, err := lookupGroupId(gid, groupFilePath)
if err != nil || group == nil {
return "", err
}
return group.Name, nil
}
func lookupGroupId(id string, filepath string) (*Group, error) {
f, err := os.Open(filepath)
if err != nil {
return nil, err
}
defer f.Close()
return findGroupId(id, f)
}
func lookupUserId(uid string, filepath string) (*User, error) {
f, err := os.Open(filepath)
if err != nil {
return nil, err
}
defer f.Close()
return findUserId(uid, f)
}
// readColonFile parses r as an /etc/group or /etc/passwd style file, running
// fn for each row. readColonFile returns a value, an error, or (nil, nil) if
// the end of the file is reached without a match.
//
// readCols is the minimum number of colon-separated fields that will be passed
// to fn; in a long line additional fields may be silently discarded.
//
//revive:disable-next-line:cognitive-complexity,cyclomatic
func readColonFile(r io.Reader, fn lineFunc, readCols int) (v any, err error) {
rd := bufio.NewReader(r)
// Read the file line-by-line.
for {
var isPrefix bool
var wholeLine []byte
// Read the next line. We do so in chunks (as much as reader's
// buffer is able to keep), check if we read enough columns
// already on each step and store final result in wholeLine.
for {
var line []byte
line, isPrefix, err = rd.ReadLine()
if err != nil {
// We should return (nil, nil) if EOF is reached
// without a match.
if err == io.EOF {
err = nil
}
return nil, err
}
// Simple common case: line is short enough to fit in a
// single reader's buffer.
if !isPrefix && len(wholeLine) == 0 {
wholeLine = line
break
}
wholeLine = append(wholeLine, line...)
// Check if we read the whole line (or enough columns)
// already.
if !isPrefix || bytes.Count(wholeLine, []byte{':'}) >= readCols {
break
}
}
// There's no spec for /etc/passwd or /etc/group, but we try to follow
// the same rules as the glibc parser, which allows comments and blank
// space at the beginning of a line.
wholeLine = bytes.TrimSpace(wholeLine)
if len(wholeLine) == 0 || wholeLine[0] == '#' {
continue
}
v, err = fn(wholeLine)
if v != nil || err != nil {
return
}
// If necessary, skip the rest of the line
for ; isPrefix; _, isPrefix, err = rd.ReadLine() {
if err != nil {
// We should return (nil, nil) if EOF is reached without a match.
if err == io.EOF {
err = nil
}
return nil, err
}
}
}
}
func matchGroupIndexValue(value string, idx int) lineFunc {
var leadColon string
if idx > 0 {
leadColon = ":"
}
substr := []byte(leadColon + value + ":")
return func(line []byte) (v any, err error) {
if !bytes.Contains(line, substr) || bytes.Count(line, []byte(":")) < 3 {
return
}
// wheel:*:0:root
parts := strings.SplitN(string(line), ":", 4)
if len(parts) < 4 || parts[0] == "" || parts[idx] != value ||
// If the file contains +foo and you search for "foo", glibc
// returns an "invalid argument" error. Similarly, if you search
// for a gid for a row where the group name starts with "+" or "-",
// glibc fails to find the record.
parts[0][0] == '+' || parts[0][0] == '-' {
return
}
if _, err := strconv.Atoi(parts[2]); err != nil {
//nolint:nilerr
return nil, nil
}
return &Group{Name: parts[0], Gid: parts[2]}, nil
}
}
func findGroupId(id string, r io.Reader) (*Group, error) {
if v, err := readColonFile(r, matchGroupIndexValue(id, 2), 3); err != nil {
return nil, err
} else if v != nil {
return v.(*Group), nil
}
return nil, errors.New("Unknown groupId Error, GID: " + id)
}
// returns a *User for a row if that row's has the given value at the
// given index.
func matchUserIndexValue(value string, idx int) lineFunc {
var leadColon string
if idx > 0 {
leadColon = ":"
}
substr := []byte(leadColon + value + ":")
return func(line []byte) (v any, err error) {
if !bytes.Contains(line, substr) || bytes.Count(line, []byte(":")) < 6 {
return
}
// kevin:x:1005:1006::/home/kevin:/usr/bin/zsh
parts := strings.SplitN(string(line), ":", 7)
if len(parts) < 6 || parts[idx] != value || parts[0] == "" ||
parts[0][0] == '+' || parts[0][0] == '-' {
return
}
if _, err := strconv.Atoi(parts[2]); err != nil {
//nolint:nilerr
return nil, nil
}
if _, err := strconv.Atoi(parts[3]); err != nil {
//nolint:nilerr
return nil, nil
}
u := &User{
Username: parts[0],
Uid: parts[2],
Gid: parts[3],
Name: parts[4],
HomeDir: parts[5],
}
// The pw_gecos field isn't quite standardized. Some docs
// say: "It is expected to be a comma separated list of
// personal data where the first item is the full name of the
// user."
if i := strings.Index(u.Name, ","); i >= 0 {
u.Name = u.Name[:i]
}
return u, nil
}
}
func findUserId(uid string, r io.Reader) (*User, error) {
_, e := strconv.Atoi(uid)
if e != nil {
return nil, errors.New("user: invalid userid " + uid)
}
if v, err := readColonFile(r, matchUserIndexValue(uid, 2), 6); err != nil {
return nil, err
} else if v != nil {
return v.(*User), nil
}
return nil, errors.New("Unknown UserId Error, UID: " + uid)
}