| # Copyright 2015 The ChromiumOS Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Logic to read the set of users and groups installed on a system.""" |
| |
| import collections |
| import logging |
| import os |
| |
| from chromite.lib import locking |
| from chromite.lib import osutils |
| |
| |
| # These fields must be in the order expected in /etc/passwd entries. |
| User = collections.namedtuple( |
| "User", ("user", "password", "uid", "gid", "gecos", "home", "shell") |
| ) |
| |
| # These fields must be in the order expected in /etc/group entries. |
| Group = collections.namedtuple("Group", ("group", "password", "gid", "users")) |
| |
| |
| def UserToEntry(user): |
| """Returns the database file entry corresponding to |user|.""" |
| return ":".join( |
| [ |
| user.user, |
| user.password, |
| str(user.uid), |
| str(user.gid), |
| user.gecos, |
| user.home, |
| user.shell, |
| ] |
| ) |
| |
| |
| def GroupToEntry(group): |
| """Returns the database file entry corresponding to |group|.""" |
| return ":".join( |
| [group.group, group.password, str(group.gid), ",".join(group.users)] |
| ) |
| |
| |
| class UserDB: |
| """An object that understands the users and groups installed on a system.""" |
| |
| # Number of times to attempt to acquire the write lock on a database. |
| # The max wait time for the lock is the nth triangular number of seconds. |
| # So in this case, T(24) * 1 second = 300 seconds. |
| _DB_LOCK_RETRIES = 24 |
| |
| def __init__(self, sysroot) -> None: |
| self._sysroot = sysroot |
| self._user_cache = None |
| self._group_cache = None |
| |
| @property |
| def _user_db_file(self): |
| """Returns path to user database (aka /etc/passwd in the sysroot).""" |
| return os.path.join(self._sysroot, "etc", "passwd") |
| |
| @property |
| def _group_db_file(self): |
| """Returns path to group database (aka /etc/group in the sysroot).""" |
| return os.path.join(self._sysroot, "etc", "group") |
| |
| @property |
| def _users(self): |
| """Returns a list of User tuples.""" |
| if self._user_cache is not None: |
| return self._user_cache |
| |
| self._user_cache = {} |
| passwd_contents = osutils.ReadFile(self._user_db_file) |
| |
| for line in passwd_contents.splitlines(): |
| pieces = line.split(":") |
| if len(pieces) != 7: |
| logging.warning( |
| 'Ignored invalid line in users file: "%s"', line |
| ) |
| continue |
| |
| user, password, uid, gid, gecos, home, shell = pieces |
| |
| try: |
| uid_as_int = int(uid) |
| gid_as_int = int(gid) |
| except ValueError: |
| logging.warning( |
| "Ignored invalid uid (%s) or gid (%s).", uid, gid |
| ) |
| continue |
| |
| if user in self._user_cache: |
| logging.warning( |
| 'Ignored duplicate user definition for "%s".', user |
| ) |
| continue |
| |
| self._user_cache[user] = User( |
| user=user, |
| password=password, |
| uid=uid_as_int, |
| gid=gid_as_int, |
| gecos=gecos, |
| home=home, |
| shell=shell, |
| ) |
| return self._user_cache |
| |
| @property |
| def _groups(self): |
| """Returns a list of Group tuples.""" |
| if self._group_cache is not None: |
| return self._group_cache |
| |
| self._group_cache = {} |
| group_contents = osutils.ReadFile(self._group_db_file) |
| |
| for line in group_contents.splitlines(): |
| pieces = line.split(":") |
| if len(pieces) != 4: |
| logging.warning( |
| 'Ignored invalid line in group file: "%s"', line |
| ) |
| continue |
| |
| group, password, gid, users = pieces |
| |
| try: |
| gid_as_int = int(gid) |
| except ValueError: |
| logging.warning("Ignored invalid or gid (%s).", gid) |
| continue |
| |
| if group in self._group_cache: |
| logging.warning( |
| 'Ignored duplicate group definition for "%s".', group |
| ) |
| continue |
| |
| users = users.split(",") |
| |
| self._group_cache[group] = Group( |
| group=group, password=password, gid=gid_as_int, users=users |
| ) |
| return self._group_cache |
| |
| def GetUserEntry(self, username, skip_lock=False): |
| """Returns a user's database entry. |
| |
| Args: |
| username: name of user to get the entry for. |
| skip_lock: True iff we should skip getting a lock before reading the |
| database. |
| |
| Returns: |
| database entry as a string. |
| """ |
| if skip_lock: |
| return UserToEntry(self._users[username]) |
| |
| # Clear the user cache to force ourselves to reparse while holding a |
| # lock. |
| self._user_cache = None |
| |
| with locking.PortableLinkLock( |
| self._user_db_file + ".lock", max_retry=self._DB_LOCK_RETRIES |
| ): |
| return UserToEntry(self._users[username]) |
| |
| def GetGroupEntry(self, groupname, skip_lock=False): |
| """Returns a group's database entry. |
| |
| Args: |
| groupname: name of group to get the entry for. |
| skip_lock: True iff we should skip getting a lock before reading the |
| database. |
| |
| Returns: |
| database entry as a string. |
| """ |
| if skip_lock: |
| return GroupToEntry(self._groups[groupname]) |
| |
| # Clear the group cache to force ourselves to reparse while holding a |
| # lock. |
| self._group_cache = None |
| |
| with locking.PortableLinkLock( |
| self._group_db_file + ".lock", max_retry=self._DB_LOCK_RETRIES |
| ): |
| return GroupToEntry(self._groups[groupname]) |
| |
| def UserExists(self, username): |
| """Returns True iff a user called |username| exists in the database. |
| |
| Args: |
| username: name of a user (e.g. 'root') |
| |
| Returns: |
| True iff the given |username| has an entry in /etc/passwd. |
| """ |
| return username in self._users |
| |
| def GroupExists(self, groupname): |
| """Returns True iff a group called |groupname| exists in the database. |
| |
| Args: |
| groupname: name of a group (e.g. 'root') |
| |
| Returns: |
| True iff the given |groupname| has an entry in /etc/group. |
| """ |
| return groupname in self._groups |
| |
| def ResolveUsername(self, username): |
| """Resolves a username to a uid. |
| |
| Args: |
| username: name of a user (e.g. 'root') |
| |
| Returns: |
| The uid of the given username. Raises ValueError on failure. |
| """ |
| user = self._users.get(username) |
| if user: |
| return user.uid |
| |
| raise ValueError( |
| 'Could not resolve unknown user "%s" to uid.' % username |
| ) |
| |
| def ResolveGroupname(self, groupname): |
| """Resolves a groupname to a gid. |
| |
| Args: |
| groupname: name of a group (e.g. 'wheel') |
| |
| Returns: |
| The gid of the given groupname. Raises ValueError on failure. |
| """ |
| group = self._groups.get(groupname) |
| if group: |
| return group.gid |
| |
| raise ValueError( |
| 'Could not resolve unknown group "%s" to gid.' % groupname |
| ) |
| |
| def AddUser(self, user) -> None: |
| """Atomically add a user to the database. |
| |
| If a user named |user.user| already exists, this method will simply |
| return. |
| |
| Args: |
| user: user_db.User object to add to database. |
| """ |
| # Try to avoid grabbing the lock in the common case that a user already |
| # exists. |
| if self.UserExists(user.user): |
| logging.info( |
| 'Not installing user "%s" because it already existed.', |
| user.user, |
| ) |
| return |
| |
| # Clear the user cache to force ourselves to reparse. |
| self._user_cache = None |
| |
| with locking.PortableLinkLock( |
| self._user_db_file + ".lock", max_retry=self._DB_LOCK_RETRIES |
| ): |
| # Check that |user| exists under the lock in case we're racing to |
| # create this user. |
| if self.UserExists(user.user): |
| logging.info( |
| 'Not installing user "%s" because it already existed.', |
| user.user, |
| ) |
| return |
| |
| self._users[user.user] = user |
| new_users = sorted(self._users.values(), key=lambda u: u.uid) |
| contents = "\n".join([UserToEntry(u) for u in new_users]) |
| osutils.WriteFile( |
| self._user_db_file, contents, atomic=True, sudo=True |
| ) |
| print('Added user "%s" to %s:' % (user.user, self._user_db_file)) |
| print(" - password entry: %s" % user.password) |
| print(" - id: %d" % user.uid) |
| print(" - group id: %d" % user.gid) |
| print(" - gecos: %s" % user.gecos) |
| print(" - home: %s" % user.home) |
| print(" - shell: %s" % user.shell) |
| |
| def AddGroup(self, group) -> None: |
| """Atomically add a group to the database. |
| |
| If a group named |group.group| already exists, this method will simply |
| return. |
| |
| Args: |
| group: user_db.Group object to add to database. |
| """ |
| # Try to avoid grabbing the lock in the common case that a group already |
| # exists. |
| if self.GroupExists(group.group): |
| logging.info( |
| 'Not installing group "%s" because it already existed.', |
| group.group, |
| ) |
| return |
| |
| # Clear the group cache to force ourselves to reparse. |
| self._group_cache = None |
| |
| with locking.PortableLinkLock( |
| self._group_db_file + ".lock", max_retry=self._DB_LOCK_RETRIES |
| ): |
| # Check that |group| exists under the lock in case we're racing to |
| # create this group. |
| if self.GroupExists(group.group): |
| logging.info( |
| 'Not installing group "%s" because it already existed.', |
| group.group, |
| ) |
| return |
| |
| self._groups[group.group] = group |
| new_groups = sorted(self._groups.values(), key=lambda g: g.gid) |
| contents = "\n".join([GroupToEntry(g) for g in new_groups]) |
| osutils.WriteFile( |
| self._group_db_file, contents, atomic=True, sudo=True |
| ) |
| print( |
| 'Added group "%s" to %s:' % (group.group, self._group_db_file) |
| ) |
| print(" - group id: %d" % group.gid) |
| print(" - password entry: %s" % group.password) |
| print(" - user list: %s" % ",".join(group.users)) |