blob: cd78bc99a58210657f0ccb0501acd45da02237a7 [file] [log] [blame]
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright (c) 2014 The Chromium OS Authors. All rights reserved.
# 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"""
from __future__ import print_function
import argparse
import collections
import glob
import os
import sys
# Objects to hold group/user accounts.
Group = collections.namedtuple('Group', ('group', 'password', 'gid', 'users',
'defunct'))
User = collections.namedtuple('User', ('user', 'password', 'uid', 'gid',
'gecos', 'home', 'shell', 'defunct'))
def _ParseAccount(name, name_key, content, obj, defaults):
"""Parse the raw data in |content| and return a new |obj|"""
d = defaults.copy()
# 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')
for line in content.splitlines():
if not line or line.startswith('#'):
continue
# Disallow leading & trailing whitespace.
if line != line.strip():
raise ValueError('Trim leading/trailing whitespace: "%s"' % line)
key, val = line.split(':')
if key not in obj._fields:
raise ValueError('unknown key: %s' % key)
d[key] = val
missing_keys = set(obj._fields) - set(d.keys())
if missing_keys:
raise ValueError('missing keys: %s' % ' '.join(missing_keys))
if d[name_key] != name:
raise ValueError('account "%s" has the %s field set to "%s"' %
(name, name_key, d[name_key]))
return obj(**d)
def ParseGroup(name, content):
"""Parse |content| as a Group object"""
defaults = {
'password': '!',
'users': '',
'defunct': '',
}
return _ParseAccount(name, 'group', content, Group, defaults)
def ParseUser(name, content):
"""Parse |content| as a User object"""
defaults = {
'gecos': '',
'home': '/dev/null',
'password': '!',
'shell': '/bin/false',
'defunct': '',
}
return _ParseAccount(name, 'user', content, User, defaults)
def AlignWidths(arr):
"""Calculate a set of widths for alignment
Args:
arr: An array of collections.namedtuple objects
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, order):
"""Display |accts| as a table using |order| for field ordering
Args:
accts: An array of collections.namedtuple objects
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('%-*s ' % (widths[k], getattr(obj, k)), end='')
print()
for a in [header_obj] + sorted(accts, key=sorter):
p(a)
def CheckConsistency(groups, users):
"""Run various consistency/sanity 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('error: duplicate gid found: %s: %s' % (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('error: duplicate uid found: %s: %s' % (uid, dupes), file=sys.stderr)
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('error: group "%s" wants missing user "%s"' %
(group.group, 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('%s:' % 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(' %s: %s' % (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='*',
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 = os.path.dirname(os.path.realpath(__file__))
accounts = (glob.glob(os.path.join(accounts_dir, 'group', '*')) +
glob.glob(os.path.join(accounts_dir, 'user', '*')))
consistency_check = True
groups = []
users = []
for f in accounts:
try:
content = open(f).read()
if not content:
raise ValueError('empty file')
if content[-1] != '\n':
raise ValueError('missing trailing newline')
name = os.path.basename(f)
if 'group:' in content:
groups.append(ParseGroup(name, content))
else:
users.append(ParseUser(name, content))
except ValueError as e:
print('error: %s: %s' % (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:]))