blob: 0d043898c5004a382c226f01f78c0ba4d7066d02 [file] [log] [blame]
#!/usr/bin/env python3
# Copyright 2014 The ChromiumOS Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Pretty print (and check) a set of group/user accounts"""
import argparse
import collections
import os
from pathlib import Path
import re
import sys
from typing import Dict, List, NamedTuple
assert sys.version_info >= (
3,
6,
), f"Python 3.6+ required, but found {sys.version_info}"
# Regex to match valid account names.
VALID_ACCT_NAME_RE = re.compile(r"^[a-z][a-z0-9_-]*[a-z0-9]$")
class Group(NamedTuple):
"""A group account."""
# NB: Order is not the same as /etc/group. Don't rely on it.
group: str
gid: str
password: str = "!"
users: str = ""
defunct: str = ""
class User(NamedTuple):
"""A user account."""
# NB: Order is not the same as /etc/passwd. Don't rely on it.
user: str
uid: str
gid: str
password: str = "!"
gecos: str = ""
home: str = "/dev/null"
shell: str = "/bin/false"
defunct: str = ""
def _ParseAccount(name, name_key, content, obj):
"""Parse the raw data in |content| and return a new |obj|."""
# Make sure files all have a trailing newline.
if not content.endswith("\n"):
raise ValueError("File needs a trailing newline")
# Disallow leading & trailing blank lines.
if content.startswith("\n"):
raise ValueError("Delete leading blank lines")
if content.endswith("\n\n"):
raise ValueError("Delete trailing blank lines")
d = {}
for line in content.splitlines():
if not line or line.startswith("#"):
continue
# Disallow leading & trailing whitespace.
if line != line.strip():
raise ValueError(f'Trim leading/trailing whitespace: "{line}"')
key, val = line.split(":")
if key not in obj._fields:
raise ValueError(f"unknown key: {key}")
d[key] = val
unknown_keys = set(d.keys()) - set(obj._fields)
if unknown_keys:
raise ValueError(f'unknown keys: {" ".join(unknown_keys)}')
if d[name_key] != name:
raise ValueError(
f'account "{name}" has "{name_key}" field set to "{d[name_key]}"'
)
return obj(**d)
def ParseGroup(name, content):
"""Parse |content| as a Group object."""
return _ParseAccount(name, "group", content, Group)
def ParseUser(name, content):
"""Parse |content| as a User object."""
return _ParseAccount(name, "user", content, User)
def AlignWidths(arr: List[NamedTuple]) -> Dict:
"""Calculate a set of widths for alignment.
Args:
arr: An array of accounts.
Returns:
A dict whose fields have the max length.
"""
d = {}
for f in arr[0]._fields:
d[f] = 0
for a in arr:
for f in a._fields:
d[f] = max(d[f], len(getattr(a, f)))
return d
def DisplayAccounts(accts: List[NamedTuple], order):
"""Display |accts| as a table using |order| for field ordering.
Args:
accts: An array of accounts.
order: The order in which to display the members.
"""
obj = type(accts[0])
header_obj = obj(**dict([(k, (v if v else k).upper()) for k, v in order]))
keys = [k for k, _ in order]
sorter = lambda x: int(getattr(x, keys[0]))
widths = AlignWidths([header_obj] + accts)
def p(obj):
for k in keys:
print(f"{getattr(obj, k):<{widths[k] + 1}}", end="")
print()
for a in [header_obj] + sorted(accts, key=sorter):
p(a)
def CheckConsistency(groups, users):
"""Run various consistency checks on the lists of groups/users.
This does not check for syntax/etc... errors on a per-account basis as the
main _ParseAccount function above took care of that.
Args:
groups: A list of Group objects.
users: A list of User objects.
Returns:
True if everything is consistent.
"""
ret = True
gid_counts = collections.Counter(x.gid for x in groups)
for gid in [k for k, v in gid_counts.items() if v > 1]:
ret = False
dupes = ", ".join(x.group for x in groups if x.gid == gid)
print(f"error: duplicate gid found: {gid}: {dupes}", file=sys.stderr)
uid_counts = collections.Counter(x.uid for x in users)
for uid in [k for k, v in uid_counts.items() if v > 1]:
ret = False
dupes = ", ".join(x.user for x in users if x.uid == uid)
print(f"error: duplicate uid found: {uid}: {dupes}", file=sys.stderr)
for group in groups:
if not VALID_ACCT_NAME_RE.match(group.group):
print(f"error: invalid group account name: {group.group}")
for user in users:
if not VALID_ACCT_NAME_RE.match(user.user):
print(f"error: invalid user account name: {user.user}")
found_users = set(x.user for x in users)
want_users = set()
for group in groups:
if group.users:
want_users.update(group.users.split(","))
missing_users = want_users - found_users
if missing_users:
ret = False
print("error: group lists unknown users", file=sys.stderr)
for group in groups:
for user in missing_users:
if user in group.users.split(","):
print(
f'error: group "{group.group}" wants missing user '
f'"{user}"',
file=sys.stderr,
)
return ret
def _FindFreeIds(accts, key, low_id, high_id):
"""Find all free ids in |accts| between |low_id| and |high_id| (inclusive).
Args:
accts: An iterable of account objects.
key: The member of the account object holding the id.
low_id: The first id to look for.
high_id: The last id to look for.
Returns:
A sorted list of free ids.
"""
free_accts = set(range(low_id, high_id + 1))
used_accts = set(int(getattr(x, key)) for x in accts)
return sorted(free_accts - used_accts)
def ShowNextFree(groups, users):
"""Display next set of free groups/users."""
RANGES = (
("CrOS daemons", 20100, 29999),
("FUSE daemons", 300, 399),
("Standalone", 400, 499),
("Namespaces", 600, 699),
)
for name, low_id, high_id in RANGES:
print(f"{name}:")
for accts, key in ((groups, "gid"), (users, "uid")):
if accts:
free_accts = _FindFreeIds(accts, key, low_id, high_id)
if len(free_accts) > 10:
free_accts = free_accts[0:10] + ["..."]
print(f" {key}: {free_accts}")
print()
def GetParser():
"""Creates the argparse parser."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--show-free",
default=False,
action="store_true",
help="Find next available UID/GID",
)
parser.add_argument(
"--lint",
default=False,
action="store_true",
help="Validate all the user accounts",
)
parser.add_argument(
"account", nargs="*", type=Path, help="Display these account files only"
)
return parser
def main(argv):
parser = GetParser()
opts = parser.parse_args(argv)
accounts = opts.account
consistency_check = False
if not accounts:
accounts_dir = Path(__file__).resolve().parent
accounts = list((accounts_dir / "group").glob("*")) + list(
(accounts_dir / "user").glob("*")
)
consistency_check = True
groups = []
users = []
for f in accounts:
try:
content = f.read_text(encoding="utf-8")
if not content:
raise ValueError("empty file")
if content[-1] != "\n":
raise ValueError("missing trailing newline")
if "group:" in content:
groups.append(ParseGroup(f.name, content))
else:
users.append(ParseUser(f.name, content))
except ValueError as e:
print(f"error: {f}: {e}", file=sys.stderr)
return os.EX_DATAERR
if opts.show_free:
ShowNextFree(groups, users)
return
if not opts.lint:
if groups:
order = (
("gid", ""),
("group", ""),
("password", "pass"),
("users", ""),
("defunct", ""),
)
DisplayAccounts(groups, order)
if users:
if groups:
print()
order = (
("uid", ""),
("gid", ""),
("user", ""),
("shell", ""),
("home", ""),
("password", "pass"),
("gecos", ""),
("defunct", ""),
)
DisplayAccounts(users, order)
if consistency_check and not CheckConsistency(groups, users):
return os.EX_DATAERR
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))