begins rename to AccountTree core_developer_branch
authorThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Thu, 22 Oct 2026 07:22:47 +0000 (07:22 +0000)
committerThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Thu, 22 Oct 2026 07:22:47 +0000 (07:22 +0000)
23 files changed:
developer/authored/AccountTree.copy/SubU [new file with mode: 0755]
developer/authored/AccountTree.copy/SubU_admin [new file with mode: 0755]
developer/authored/AccountTree.copy/account.py [new file with mode: 0644]
developer/authored/AccountTree.copy/authority.py [new file with mode: 0644]
developer/authored/AccountTree.copy/common.py [new file with mode: 0644]
developer/authored/AccountTree.copy/database.py [new file with mode: 0644]
developer/authored/AccountTree.copy/resource_kind.py [new file with mode: 0644]
developer/authored/AccountTree.copy/same_user.py [new file with mode: 0644]
developer/authored/AccountTree.copy/schema_storage.sql [new file with mode: 0644]
developer/authored/AccountTree.copy/schema_system.sql [new file with mode: 0644]
developer/authored/AccountTree.copy/storage.py [new file with mode: 0644]
developer/authored/AccountTree.copy/system.py [new file with mode: 0644]
developer/authored/AccountTree.copy/window.py [new file with mode: 0644]
developer/authored/deprecated/developer/login/subu_login
developer/authored/replace_from_tar [new file with mode: 0755]
developer/document/SubU_window_Thomas_developer.png [deleted file]
document/SubU_window_Thomas_developer.png [new file with mode: 0644]
document/design_AccountTree.html [new file with mode: 0644]
document/design_SubU.html [new file with mode: 0644]
setup
shared/project-dependency/Python.init
shared/project-dependency/Python.org
shared/tool/setup

diff --git a/developer/authored/AccountTree.copy/SubU b/developer/authored/AccountTree.copy/SubU
new file mode 100755 (executable)
index 0000000..5fdb41e
--- /dev/null
@@ -0,0 +1,85 @@
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+SubU
+
+Manages the resources of users. A SubU, a subordinate user, belongs to
+another user, its MasU, and every resource a user holds, its SubUs among
+them, is allocated through this tool. root is the MasU at the top.
+
+This is the application shell. It shows what exists and greys out every
+option that has no implementation yet. It changes nothing, and runs with
+the privileges of the user who started it.
+
+Usage: SubU <command> [argument ...]
+  help | -h | --help   print this message (also printed when given no arguments)
+  window [<user>]      open the resource window for <user>, default yourself
+  list [<user>]        print the SubUs of <user>, default yourself
+  resource             print the kinds of resource, and which have an allocator
+"""
+
+import sys ,os
+sys.path.insert(0 ,os.path.dirname(os.path.realpath(__file__)))
+
+import account
+import resource_kind
+
+
+def print_help():
+  print(__doc__.strip())
+  return 0
+
+def user_argument(rest ,command):
+  if len(rest) > 1:
+    print(f"Error: {command} takes at most one user name." ,file=sys.stderr)
+    return None
+  if not rest:
+    return account.acting_account()
+  user = account.account(rest[0])
+  if user is None:
+    print(f"Error: there is no user named '{rest[0]}'." ,file=sys.stderr)
+  return user
+
+def command_window(rest):
+  user = user_argument(rest ,"window")
+  if user is None: return 1
+  import window
+  return window.run(user ,account.acting_account())
+
+def command_list(rest):
+  user = user_argument(rest ,"list")
+  if user is None: return 1
+  for subu in account.subu_list(user.name):
+    print(f"{subu.name:<24}{subu.uid:>7}  {subu.home}")
+  return 0
+
+def command_resource(rest):
+  if rest:
+    print("Error: resource takes no arguments." ,file=sys.stderr)
+    return 1
+  for group ,kind_list in resource_kind.kind_list_by_group():
+    print(resource_kind.GROUP_TITLE[group])
+    for kind in kind_list:
+      state = "allocator" if kind.implemented else "not written"
+      print(f"  {kind.name:<20}{state:<13}{kind.description}")
+  return 0
+
+COMMAND = {
+  "window": command_window,
+  "list": command_list,
+  "resource": command_resource,
+}
+
+def CLI(argv=None):
+  if argv is None: argv = sys.argv[1:]
+  if not argv or argv[0] in ("help" ,"-h" ,"--help"): return print_help()
+  command ,rest = argv[0] ,argv[1:]
+  if command not in COMMAND:
+    print(f"Error: unknown command '{command}'." ,file=sys.stderr)
+    print_help()
+    return 1
+  return COMMAND[command](rest)
+
+
+if __name__ == "__main__":
+  sys.exit(CLI())
diff --git a/developer/authored/AccountTree.copy/SubU_admin b/developer/authored/AccountTree.copy/SubU_admin
new file mode 100755 (executable)
index 0000000..1623fc7
--- /dev/null
@@ -0,0 +1,248 @@
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+SubU_admin
+
+The superuser's program. It runs as root, and calls the work functions
+directly, so it works whether or not the SubU server is running. Users make
+and manage their own SubUs with the SubU program.
+
+There are two kinds of database:
+  system database    /var/lib/SubU/system.sqlite3, this system's own record,
+                     identified by its system uuid. Its system map is the
+                     authority for which storage users have accounts here.
+  storage database   <SubU dir>/database.sqlite3, the users kept on a storage,
+                     identified by its database uuid.
+
+A storage is a persistent storage device: the disk inside the machine, or a
+drive plugged in. Its SubU dir is <storage root>/home/SubU and holds the
+storage database and user_data, the users' homes. On the disk the system runs
+from, the SubU dir is /home/SubU; on a drive mounted at /mnt/Eagle it is
+/mnt/Eagle/home/SubU.
+
+A user specifier names a user in one place:
+  system <host name> <account>     an account on this system
+  storage <SubU dir> <user>        a user of the storage database in that SubU dir
+
+Usage: SubU_admin <command> [argument ...]
+       SubU_admin preview <command> [argument ...]
+  help | -h | --help             print this message (also printed when given no arguments)
+
+  system make [<first system uid> <last system uid>]
+                                 make the system database, with a new system uuid and
+                                 the range of system uids SubU gives out (default
+                                 2000 to 59999)
+  system show                    print the system database, and verify it
+  system delete                  delete the system database; refused while any account
+                                 is the same user as a storage user
+
+  storage make <SubU dir>        make the SubU dir with an empty storage database and
+                                 user_data
+  storage show <SubU dir>        print the storage database, and verify it
+  storage delete <SubU dir> [--user-data] [--forget-other-systems]
+                                 undo on this system what SubU did for the storage
+                                 database's users, then delete the SubU dir. Every
+                                 check is made first, and a failed check changes
+                                 nothing. Homes holding files are deleted only with
+                                 --user-data
+
+  user make <SubU dir> <name> [--uid <database uid>] [--masu <name>]
+                                 make a user in the storage database, with its home in
+                                 user_data; its database uid is the next counting down
+                                 from 1999999999, or --uid keeps an existing uid
+
+  is-same-user <user specifier> <user specifier> [--make-account]
+                                 record in the system map that a system account and a
+                                 storage user are the same user. The account is adopted
+                                 when it exists; --make-account makes it, with a new
+                                 system uid, when it does not
+  is-not-same-user <user specifier> <user specifier>
+                                 undo is-same-user: unmount the home, delete an account
+                                 SubU made, keep an adopted one, retire the system uid
+  same-user show [storage <SubU dir>]
+                                 print who is the same user as whom on this system
+  same-user suggest storage <SubU dir>
+                                 print is-same-user commands proposed by matching names,
+                                 to read and run; changes nothing
+
+  attach <SubU dir>              mount the home of each storage user the system map
+                                 names at /home/<account>, nosuid and nodev
+  detach <SubU dir>              unmount them; refused for an account that is logged in
+
+  limit set <account> <maximum>  set how many SubUs an account may have below it;
+                                 an account with no limit set may have none
+
+  preview <command>              print the log of what <command> would do, change nothing
+
+The order on a new system: system make, storage make, user make, is-same-user,
+attach. To undo: storage delete, then system delete.
+"""
+
+import sys ,os
+sys.path.insert(0 ,os.path.dirname(os.path.realpath(__file__)))
+
+import common
+import system
+import storage
+import same_user
+from database import DatabaseError
+
+# first words that take a second word to make the command
+OBJECT = ("system" ,"storage" ,"user" ,"same-user" ,"limit")
+
+# commands that only read, and so may be run by anyone with read access
+READ_ONLY = ("system show" ,"storage show" ,"same-user show" ,"same-user suggest")
+
+
+def print_help():
+  print(__doc__.strip())
+  return 0
+
+def flag_value(rest ,flag):
+  """
+  Remove '--flag value' from rest and return value, or None.
+  """
+  if flag in rest:
+    i = rest.index(flag)
+    if i + 1 >= len(rest):
+      raise ValueError(f"{flag} needs a value")
+    value = rest[i + 1]
+    del rest[i:i + 2]
+    return value
+  return None
+
+def flag_present(rest ,flag):
+  if flag in rest:
+    rest.remove(flag)
+    return True
+  return False
+
+def integer(text ,what):
+  try:
+    return int(text)
+  except ValueError:
+    raise ValueError(f"{what} must be a whole number, not '{text}'")
+
+def user_specifier_list(rest):
+  """
+  Parse user specifiers from rest: 'system <host name> <account>' and
+  'storage <SubU dir> <user>'.
+  """
+  result = []
+  i = 0
+  while i < len(rest):
+    kind = rest[i]
+    if kind not in ("system" ,"storage") or i + 2 >= len(rest):
+      raise ValueError(f"a user specifier is 'system <host name> <account>' or 'storage <SubU dir> <user>', not '{' '.join(rest[i:])}'")
+    if kind == "system":
+      result.append({"kind": "system" ,"host": rest[i + 1] ,"account": rest[i + 2]})
+    else:
+      result.append({"kind": "storage" ,"subu_dir": rest[i + 1] ,"user": rest[i + 2]})
+    i += 3
+  return result
+
+def system_and_storage(specifier_list):
+  """
+  In 5.0, is-same-user relates a system account and a storage user, given in
+  either order.
+  """
+  kind = sorted(s["kind"] for s in specifier_list)
+  if len(specifier_list) != 2 or kind != ["storage" ,"system"]:
+    raise ValueError("in 5.0 the two user specifiers are one system account and one storage user")
+  system_spec = next(s for s in specifier_list if s["kind"] == "system")
+  storage_spec = next(s for s in specifier_list if s["kind"] == "storage")
+  return system_spec ,storage_spec
+
+def CLI(argv=None):
+  if argv is None: argv = sys.argv[1:]
+  if not argv or argv[0] in ("help" ,"-h" ,"--help"): return print_help()
+
+  preview = argv[0] == "preview"
+  if preview: argv = argv[1:]
+  if not argv:
+    print("Error: 'preview' needs a command after it." ,file=sys.stderr)
+    return 1
+
+  command ,rest = argv[0] ,list(argv[1:])
+  if command in OBJECT and rest:
+    command ,rest = f"{command} {rest[0]}" ,rest[1:]
+
+  if command not in READ_ONLY and os.geteuid() != 0 and not preview:
+    print("Error: SubU_admin changes the system and must be run as root." ,file=sys.stderr)
+    return 1
+
+  log = common.Log(preview)
+  try:
+    # ---- read only
+    if command == "system show" and not rest:
+      return system.system_show()
+    if command == "storage show" and len(rest) == 1:
+      return storage.storage_show(rest[0])
+    if command == "same-user show" and len(rest) in (0 ,2):
+      if rest and rest[0] != "storage": return usage_error(command)
+      return same_user.same_user_show({"subu_dir": rest[1]} if rest else None)
+    if command == "same-user suggest" and len(rest) == 2 and rest[0] == "storage":
+      return same_user.same_user_suggest({"subu_dir": rest[1]})
+
+    # ---- changes
+    if command == "system make" and len(rest) in (0 ,2):
+      if rest:
+        first ,last = integer(rest[0] ,"<first system uid>") ,integer(rest[1] ,"<last system uid>")
+      else:
+        first ,last = system.DEFAULT_SYSTEM_UID_FIRST ,system.DEFAULT_SYSTEM_UID_LAST
+      header(command ,None ,preview)
+      system.system_make(first ,last ,log ,preview)
+    elif command == "system delete" and not rest:
+      header(command ,None ,preview)
+      system.system_delete(log ,preview)
+    elif command == "storage make" and len(rest) == 1:
+      header(command ,rest[0] ,preview)
+      storage.storage_make(rest[0] ,log ,preview)
+    elif command == "storage delete" and rest:
+      user_data_too = flag_present(rest ,"--user-data")
+      forget_other_systems = flag_present(rest ,"--forget-other-systems")
+      if len(rest) != 1: return usage_error(command)
+      header(command ,rest[0] ,preview)
+      storage.storage_delete(rest[0] ,user_data_too ,forget_other_systems ,log ,preview)
+    elif command == "user make" and len(rest) >= 2:
+      database_uid = flag_value(rest ,"--uid")
+      masu = flag_value(rest ,"--masu")
+      if len(rest) != 2: return usage_error(command)
+      header(command ,rest[0] ,preview)
+      storage.user_make(rest[0] ,rest[1] ,integer(database_uid ,"--uid") if database_uid else None ,masu ,log ,preview)
+    elif command == "is-same-user" and rest:
+      make_account = flag_present(rest ,"--make-account")
+      system_spec ,storage_spec = system_and_storage(user_specifier_list(rest))
+      header(command ,None ,preview)
+      same_user.is_same_user(system_spec ,storage_spec ,make_account ,log ,preview)
+    elif command == "is-not-same-user" and rest:
+      system_spec ,storage_spec = system_and_storage(user_specifier_list(rest))
+      header(command ,None ,preview)
+      same_user.is_not_same_user(system_spec ,storage_spec ,log ,preview)
+    elif command == "attach" and len(rest) == 1:
+      header(command ,rest[0] ,preview)
+      storage.attach(rest[0] ,log ,preview)
+    elif command == "detach" and len(rest) == 1:
+      header(command ,rest[0] ,preview)
+      storage.detach(rest[0] ,log ,preview)
+    elif command == "limit set" and len(rest) == 2:
+      header(command ,None ,preview)
+      system.limit_set(rest[0] ,integer(rest[1] ,"<maximum>") ,log ,preview)
+    else:
+      return usage_error(command)
+  except (DatabaseError ,ValueError) as error:
+    log.error(command ,str(error))
+  return log.summary()
+
+def header(command ,subu_dir ,preview):
+  where = f"   on {os.path.abspath(subu_dir)}" if subu_dir else ""
+  print(f"SubU_admin {'preview ' if preview else ''}{command}{where}")
+
+def usage_error(command):
+  print(f"Error: unknown command or wrong arguments: '{command}'." ,file=sys.stderr)
+  print("Run 'SubU_admin help' for the commands." ,file=sys.stderr)
+  return 1
+
+
+if __name__ == "__main__":
+  sys.exit(CLI())
diff --git a/developer/authored/AccountTree.copy/account.py b/developer/authored/AccountTree.copy/account.py
new file mode 100644 (file)
index 0000000..194738f
--- /dev/null
@@ -0,0 +1,137 @@
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+account - who the users are, and which are SubUs of which
+
+Until the SubU database exists, the MasU to SubU relation is read from the
+account names: a user named <MasU>·<SubU> is a SubU of <MasU>. root is the
+MasU of every user whose name has no separator. Everything here only reads
+/etc/passwd, so it runs with the privileges of whoever started the tool.
+
+The separator is the middle dot, U+00B7, two bytes in UTF-8. It may not
+appear inside a MasU or SubU name, so '_', '-' and '.' stay free for names.
+
+While existing accounts still carry the old separator '_', READ_OLD_SEPARATOR
+lets a name without a middle dot be split on its last '_'. Set it to False
+once the accounts are renamed; until then, a person account whose own name
+contains '_' would be misread as a SubU.
+"""
+
+import os ,pwd
+from dataclasses import dataclass
+from typing import Optional
+
+SEPARATOR = "\u00b7"
+OLD_SEPARATOR = "_"
+READ_OLD_SEPARATOR = True
+FIRST_PERSON_UID = 1000
+NOBODY_UID = 65534
+
+
+@dataclass
+class Account:
+  name: str
+  uid: int
+  home: str
+
+  @property
+  def home_exists(self) -> bool:
+    return os.path.isdir(self.home)
+
+
+def account(name: str) -> Optional[Account]:
+  try:
+    entry = pwd.getpwnam(name)
+  except KeyError:
+    return None
+  return Account(entry.pw_name ,entry.pw_uid ,entry.pw_dir)
+
+def acting_account() -> Account:
+  entry = pwd.getpwuid(os.getuid())
+  return Account(entry.pw_name ,entry.pw_uid ,entry.pw_dir)
+
+def is_person(entry) -> bool:
+  return FIRST_PERSON_UID <= entry.pw_uid != NOBODY_UID
+
+def split_name(name: str) -> tuple[str ,str]:
+  """
+  (MasU ,short name) of an account name. The MasU of a name without a
+  separator is root; root itself has no MasU.
+  """
+  if name == "root":
+    return ("" ,"root")
+  if SEPARATOR in name:
+    masu ,short = name.rsplit(SEPARATOR ,1)
+    return (masu ,short)
+  if READ_OLD_SEPARATOR and OLD_SEPARATOR in name:
+    masu ,short = name.rsplit(OLD_SEPARATOR ,1)
+    return (masu ,short)
+  return ("root" ,name)
+
+def subu_list(masu: str) -> list[Account]:
+  """
+  The SubUs of masu: the accounts whose MasU is masu.
+  """
+  result = []
+  for entry in pwd.getpwall():
+    if not is_person(entry):
+      continue
+    if entry.pw_name != masu and split_name(entry.pw_name)[0] == masu:
+      result.append(Account(entry.pw_name ,entry.pw_uid ,entry.pw_dir))
+  return sorted(result ,key=lambda a: a.name)
+
+def subu_name(masu: str ,short: str) -> str:
+  """
+  The account name for a new SubU. A short name may not hold the separator.
+  """
+  if SEPARATOR in short:
+    raise ValueError(f"a SubU name may not contain '{SEPARATOR}': {short}")
+  if masu == "root":
+    return short
+  return masu + SEPARATOR + short
+
+def masu_of(name: str) -> str:
+  return split_name(name)[0]
+
+def short_name(name: str) -> str:
+  return split_name(name)[1]
+
+
+# --------------------------------------------------------------------------------
+# state
+
+STATE_LOGGED_IN = "logged in"
+STATE_LOGGED_OUT = "logged out"
+STATE_OFFLINE = "offline"
+
+def session_uid_set() -> set[int]:
+  """
+  The uids that have at least one login session, as systemd-logind counts
+  them: a desktop login, a console login, or a machinectl shell. A user
+  whose services linger with no session open is not counted. Empty when
+  loginctl cannot be run.
+  """
+  import subprocess
+  try:
+    result = subprocess.run(["loginctl" ,"list-sessions" ,"--no-legend"],
+      capture_output=True ,text=True ,timeout=5)
+  except (OSError ,subprocess.TimeoutExpired):
+    return set()
+  uid_set = set()
+  for line in result.stdout.splitlines():
+    field = line.split()
+    if len(field) >= 2 and field[1].isdigit():
+      uid_set.add(int(field[1]))
+  return uid_set
+
+def state(user: Account ,session_uids: set[int]) -> str:
+  """
+  logged in   the user has a login session
+  logged out  no session, and the home directory is here
+  offline     no session, and the home directory is not here: it lives on a
+              storage that is not attached
+  """
+  if user.uid in session_uids:
+    return STATE_LOGGED_IN
+  if not user.home_exists:
+    return STATE_OFFLINE
+  return STATE_LOGGED_OUT
diff --git a/developer/authored/AccountTree.copy/authority.py b/developer/authored/AccountTree.copy/authority.py
new file mode 100644 (file)
index 0000000..338adb7
--- /dev/null
@@ -0,0 +1,25 @@
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+authority - the one place that answers "may this user do this?"
+
+Every change SubU makes is first put to the authority as a request: the
+acting user, the action, the target user, and the resource. Today there is
+no authority program, so every request is refused with that reason, and
+the window shows every change greyed out. Later this module becomes the
+client side of the authority program's interface; the front ends do not
+change.
+"""
+
+from dataclasses import dataclass
+
+
+@dataclass
+class Decision:
+  allowed: bool
+  reason: str
+
+
+AUTHORITY_STATUS = "no authority program yet; every change is refused"
+
+def request(actor: str ,action: str ,target: str ,resource: str = "") -> Decision:
+  return Decision(False ,AUTHORITY_STATUS)
diff --git a/developer/authored/AccountTree.copy/common.py b/developer/authored/AccountTree.copy/common.py
new file mode 100644 (file)
index 0000000..4fdd872
--- /dev/null
@@ -0,0 +1,173 @@
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+common - what the work modules of SubU_admin share: the log, running a
+command, reading accounts and mounts, and checking names and paths.
+
+Each work function is given a log and a preview flag. Everything a function
+does is decided the same way in a preview; the flag is tested only where a
+change is made: a database transaction is rolled back, and a command that
+would change the system is logged and not run.
+"""
+
+import re ,pwd ,grp ,socket ,subprocess
+from pathlib import Path
+
+HOME = Path("/home")
+SEPARATOR = "·"
+RESERVED_NAME = {"SubU"}
+
+
+class Log:
+  TAG_WIDTH = 22
+
+  # the tag a preview prints for each action tag
+  PREVIEW_TAG = {
+    "made": "would make" ,"recorded": "would record" ,"mounted": "would mount",
+    "unmounted": "would unmount" ,"adopted": "would adopt" ,"made account": "would make account",
+    "set": "would set" ,"deleted": "would delete" ,"deleted account": "would delete account",
+    "deleted group": "would delete group" ,"retired": "would retire" ,"mapped": "would map",
+  }
+
+  def __init__(self ,preview):
+    self.preview = preview
+    self.count = {}
+    self.error_count = 0
+
+  def line(self ,tag ,subject ,reason=None):
+    self.count[tag] = self.count.get(tag ,0) + 1
+    if self.preview:
+      tag = self.PREVIEW_TAG.get(tag ,tag)
+    text = f"  {tag:<{self.TAG_WIDTH}}{subject}"
+    if reason: text += f"  ({reason})"
+    print(text)
+
+  def error(self ,subject ,reason):
+    self.error_count += 1
+    print(f"  {'error':<{self.TAG_WIDTH}}{subject}  ({reason})")
+
+  def summary(self):
+    print("summary:")
+    for tag ,n in sorted(self.count.items()):
+      if self.preview: tag = self.PREVIEW_TAG.get(tag ,tag)
+      print(f"  {n:>4}  {tag}")
+    if self.error_count:
+      print(f"  {self.error_count:>4}  errors")
+    if self.preview:
+      print("preview only: nothing was changed.")
+    return 1 if self.error_count else 0
+
+
+def run(command ,log ,preview):
+  """
+  Run a command that changes the system; in a preview, only show it.
+  """
+  if preview:
+    return True
+  result = subprocess.run(command ,capture_output=True ,text=True)
+  if result.returncode != 0:
+    log.error(" ".join(command) ,result.stderr.strip() or f"exit {result.returncode}")
+    return False
+  return True
+
+def host_name() -> str:
+  return socket.gethostname()
+
+def account(name):
+  try:
+    return pwd.getpwnam(name)
+  except KeyError:
+    return None
+
+def account_by_system_uid(system_uid):
+  try:
+    return pwd.getpwuid(system_uid)
+  except KeyError:
+    return None
+
+def group(name):
+  try:
+    return grp.getgrnam(name)
+  except KeyError:
+    return None
+
+def login_defs_value(key ,default):
+  try:
+    for line in Path("/etc/login.defs").read_text(encoding="utf-8").splitlines():
+      field = line.split()
+      if len(field) >= 2 and field[0] == key and field[1].isdigit():
+        return int(field[1])
+  except OSError:
+    pass
+  return default
+
+def uid_min() -> int:
+  """
+  The lowest uid of a person's account on this system; below it are the
+  accounts of the system's own services.
+  """
+  return login_defs_value("UID_MIN" ,1000)
+
+def is_mountpoint(path):
+  return subprocess.run(["mountpoint" ,"-q" ,str(path)]).returncode == 0
+
+def mount_point_list():
+  """
+  Every mount point on the system, from /proc/self/mountinfo, where a space
+  and a few other characters in a path are written as three-digit octal escapes.
+  """
+  result = []
+  for line in Path("/proc/self/mountinfo").read_bytes().split(b"\n"):
+    field = line.split(b" ")
+    if len(field) > 4:
+      raw = re.sub(rb"\\([0-7]{3})" ,lambda m: bytes([int(m.group(1) ,8)]) ,field[4])
+      result.append(raw.decode("utf-8" ,"replace"))
+  return result
+
+def session_system_uids():
+  result = subprocess.run(["loginctl" ,"list-sessions" ,"--no-legend"] ,capture_output=True ,text=True)
+  system_uid_set = set()
+  for line in result.stdout.splitlines():
+    field = line.split()
+    if len(field) >= 2 and field[1].isdigit():
+      system_uid_set.add(int(field[1]))
+  return system_uid_set
+
+def valid_name(name):
+  if not name or len(name.encode("utf-8")) > 32:
+    return "a user name is 1 to 32 bytes"
+  if any(c in name for c in "/: \t\n") or name.startswith(("-" ,SEPARATOR)) or name.endswith(SEPARATOR):
+    return "a user name may not contain '/', ':' or spaces, nor start with '-' or begin or end with '·'"
+  if SEPARATOR * 2 in name:
+    return "a user name may not contain an empty short name"
+  if name in RESERVED_NAME:
+    return f"'{name}' is reserved: /home/{name} holds SubU's own files"
+  return None
+
+def subu_dir_problem(subu_dir):
+  """
+  A SubU dir is <storage root>/home/SubU; every storage keeps the same layout.
+  """
+  path = Path(subu_dir)
+  if path.name != "SubU" or path.parent.name != "home":
+    return f"{subu_dir} is not a SubU dir; a SubU dir is <storage root>/home/SubU, such as /home/SubU"
+  return None
+
+def delete_mount_point(target ,log ,preview):
+  """
+  Delete a mount point only when it is one attach made: an empty directory
+  owned by root, mode 0000. In a preview a home still mounted over it hides
+  it, so the real run makes the check.
+  """
+  if not target.exists():
+    return
+  if preview and is_mountpoint(target):
+    log.line("deleted" ,str(target) ,"mount point, once unmounted, if attach made it")
+    return
+  st = target.lstat()
+  if is_mountpoint(target) or not target.is_dir() or st.st_uid != 0 or (st.st_mode & 0o7777) != 0 or any(target.iterdir()):
+    log.line("kept" ,str(target) ,"not an empty mount point made by attach")
+    return
+  if not preview:
+    subprocess.run(["chattr" ,"-i" ,str(target)] ,capture_output=True)
+    target.rmdir()
+  log.line("deleted" ,str(target) ,"mount point")
diff --git a/developer/authored/AccountTree.copy/database.py b/developer/authored/AccountTree.copy/database.py
new file mode 100644 (file)
index 0000000..bceadb7
--- /dev/null
@@ -0,0 +1,253 @@
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+database - the two kinds of SubU database
+
+  storage database   <SubU dir>/database.sqlite3, identified by its database uuid
+                     The SubU dir is <storage root>/home/SubU, so on the disk a
+                     system runs from it is /home/SubU.
+  system database    /var/lib/SubU/system.sqlite3, identified by its system uuid
+
+Each kind has its own schema, schema_storage.sql and schema_system.sql, and
+both share the machinery here. Every change is made inside a transaction (see
+Database.transaction), which records the statements it ran, a random number,
+and a hash chained to the previous transaction's hash. The identity row holds
+the series number and the hash of the last transaction, the head of the chain.
+
+verify() checks the chain, and replays every recorded change into an empty
+database built from the schema, so that a database whose tables were edited
+outside a transaction, or whose record was altered, is found out.
+"""
+
+import os ,json ,sqlite3 ,hashlib ,secrets
+from uuid import uuid4
+from contextlib import contextmanager
+from datetime import datetime ,timezone
+from pathlib import Path
+
+STANDARD = "5.0"
+STANDARD_FOLLOWED = {"5.0"}
+GENESIS_HASH = "0" * 64
+
+HERE = Path(__file__).resolve().parent
+
+# kind: (schema file, name of the identity column)
+KIND = {
+  "storage": (HERE / "schema_storage.sql" ,"database_uuid"),
+  "system": (HERE / "schema_system.sql" ,"system_uuid"),
+}
+
+SYSTEM_DIR = Path("/var/lib/SubU")
+SYSTEM_DATABASE_PATH = SYSTEM_DIR / "system.sqlite3"
+
+
+def now() -> str:
+  return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S Z")
+
+def storage_database_path(subu_dir) -> Path:
+  return Path(subu_dir) / "database.sqlite3"
+
+def user_data_dir(subu_dir) -> Path:
+  return Path(subu_dir) / "user_data"
+
+def transaction_hash(previous_hash ,series ,description ,change_text ,nonce) -> str:
+  text = "\n".join([previous_hash ,str(series) ,description ,change_text ,nonce])
+  return hashlib.sha256(text.encode("utf-8")).hexdigest()
+
+
+class DatabaseError(Exception):
+  pass
+
+
+class Transaction:
+  """
+  Collects the statements of one transaction, so they can be recorded.
+  """
+
+  def __init__(self ,connection):
+    self.connection = connection
+    self.change = []
+
+  def execute(self ,statement ,parameter=()):
+    parameter = list(parameter)
+    self.change.append([statement ,parameter])
+    return self.connection.execute(statement ,parameter)
+
+
+class Database:
+
+  def __init__(self ,connection ,path ,kind):
+    self.connection = connection
+    self.path = path
+    self.kind = kind
+    self.schema_path ,self.uuid_column = KIND[kind]
+
+  # ------------------------------------------------------------------------------
+  # opening
+
+  @classmethod
+  def open(cls ,path ,kind):
+    path = Path(path)
+    if not path.is_file():
+      raise DatabaseError(f"no {kind} database at {path}")
+    connection = sqlite3.connect(str(path) ,isolation_level=None)
+    connection.row_factory = sqlite3.Row
+    database = cls(connection ,path ,kind)
+    database.check_standard()
+    return database
+
+  @classmethod
+  def make(cls ,path ,kind ,preview=False):
+    """
+    Make an empty database of the kind, with its identity row and a new uuid.
+    The directory holding it must exist. In a preview the database is built in
+    memory and discarded.
+    """
+    path = Path(path)
+    if path.exists():
+      raise DatabaseError(f"a database already exists at {path}")
+    if preview:
+      connection = sqlite3.connect(":memory:" ,isolation_level=None)
+    else:
+      connection = sqlite3.connect(str(path) ,isolation_level=None)
+      os.chmod(path ,0o600)
+    connection.row_factory = sqlite3.Row
+    database = cls(connection ,path ,kind)
+    connection.executescript(database.schema_path.read_text(encoding="utf-8"))
+    connection.execute("BEGIN")
+    connection.execute(
+      f"INSERT INTO identity({database.uuid_column} ,series ,head_hash ,created) VALUES (? ,0 ,? ,?)",
+      (str(uuid4()) ,GENESIS_HASH ,now()))
+    connection.execute("COMMIT")
+    with database.transaction(f"make {kind} database") as t:
+      t.execute("INSERT INTO standard(version) VALUES (?)" ,(STANDARD,))
+    return database
+
+  def check_standard(self):
+    followed = {row["version"] for row in self.connection.execute("SELECT version FROM standard")}
+    if not followed & STANDARD_FOLLOWED:
+      raise DatabaseError(
+        f"{self.path} follows standards {sorted(followed)}; this program follows {sorted(STANDARD_FOLLOWED)}")
+
+  def close(self):
+    self.connection.close()
+
+  # ------------------------------------------------------------------------------
+  # reading
+
+  def identity(self):
+    return self.connection.execute("SELECT * FROM identity").fetchone()
+
+  def uuid(self) -> str:
+    """
+    The database uuid of a storage database, or the system uuid of a system database.
+    """
+    return self.identity()[self.uuid_column]
+
+  def short(self) -> str:
+    """
+    The first eight digits of the uuid, for log lines.
+    """
+    return self.uuid()[:8]
+
+  def last_change(self):
+    return self.connection.execute("SELECT MAX(time) AS t FROM transaction_log").fetchone()["t"]
+
+  def rows(self ,statement ,parameter=()):
+    return self.connection.execute(statement ,list(parameter)).fetchall()
+
+  def row(self ,statement ,parameter=()):
+    return self.connection.execute(statement ,list(parameter)).fetchone()
+
+  # ------------------------------------------------------------------------------
+  # changing
+
+  @contextmanager
+  def transaction(self ,description ,preview=False):
+    """
+    with database.transaction("what this does") as t:
+      t.execute(...)
+
+    On leaving the block, the statements, a random number and the chained
+    hash are recorded, and the head of the chain moves on, all in one SQLite
+    transaction. In a preview, everything is rolled back instead.
+    """
+    connection = self.connection
+    connection.execute("BEGIN IMMEDIATE")
+    t = Transaction(connection)
+    try:
+      yield t
+      if t.change:
+        head = connection.execute("SELECT series ,head_hash FROM identity").fetchone()
+        series = head["series"] + 1
+        change_text = json.dumps(t.change ,ensure_ascii=False ,separators=(",",":"))
+        nonce = secrets.token_hex(16)
+        digest = transaction_hash(head["head_hash"] ,series ,description ,change_text ,nonce)
+        connection.execute(
+          "INSERT INTO transaction_log(series ,previous_hash ,nonce ,description ,change ,hash ,time)"
+          " VALUES (? ,? ,? ,? ,? ,? ,?)",
+          (series ,head["head_hash"] ,nonce ,description ,change_text ,digest ,now()))
+        connection.execute("UPDATE identity SET series = ? ,head_hash = ?" ,(series ,digest))
+      connection.execute("ROLLBACK" if preview else "COMMIT")
+    except BaseException:
+      connection.execute("ROLLBACK")
+      raise
+
+  # ------------------------------------------------------------------------------
+  # checking
+
+  def verify(self) -> list[str]:
+    """
+    Problems found, or an empty list.
+    """
+    problem = []
+    identity = self.identity()
+    previous = GENESIS_HASH
+    expected_series = 1
+    log = self.rows("SELECT * FROM transaction_log ORDER BY series")
+    for entry in log:
+      if entry["series"] != expected_series:
+        problem.append(f"transaction {entry['series']}: expected series {expected_series}")
+      if entry["previous_hash"] != previous:
+        problem.append(f"transaction {entry['series']}: does not follow the transaction before it")
+      digest = transaction_hash(entry["previous_hash"] ,entry["series"] ,entry["description"],
+        entry["change"] ,entry["nonce"])
+      if digest != entry["hash"]:
+        problem.append(f"transaction {entry['series']}: hash does not match its content")
+      previous = entry["hash"]
+      expected_series = entry["series"] + 1
+    last_series = log[-1]["series"] if log else 0
+    if identity["series"] != last_series or identity["head_hash"] != previous:
+      problem.append("the identity row's series and head do not match the last transaction")
+    problem.extend(self.replay_problem(log))
+    return problem
+
+  def replay_problem(self ,log) -> list[str]:
+    """
+    Replay every recorded change into an empty database, and compare every
+    table but the record itself. The identity row is compared without its
+    series and head, which the chain bookkeeping sets.
+    """
+    replay = sqlite3.connect(":memory:")
+    replay.executescript(self.schema_path.read_text(encoding="utf-8"))
+    identity = self.identity()
+    replay.execute(f"INSERT INTO identity({self.uuid_column} ,series ,head_hash ,created) VALUES (? ,0 ,? ,?)",
+      (identity[self.uuid_column] ,GENESIS_HASH ,identity["created"]))
+    try:
+      for entry in log:
+        for statement ,parameter in json.loads(entry["change"]):
+          replay.execute(statement ,parameter)
+    except sqlite3.Error as error:
+      return [f"replaying the record failed: {error}"]
+    problem = []
+    table_list = [r[0] for r in replay.execute(
+      "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT IN ('identity' ,'transaction_log')"
+      " AND name NOT LIKE 'sqlite_%' ORDER BY name")]
+    for table in table_list:
+      here = sorted((tuple(r) for r in self.connection.execute(f"SELECT * FROM {table}")) ,key=repr)
+      there = sorted((tuple(r) for r in replay.execute(f"SELECT * FROM {table}")) ,key=repr)
+      if here != there:
+        problem.append(f"table {table} differs from the replay of the record")
+    column = f"SELECT {self.uuid_column} ,created FROM identity"
+    if tuple(self.connection.execute(column).fetchone()) != tuple(replay.execute(column).fetchone()):
+      problem.append("table identity differs from the replay of the record")
+    return problem
diff --git a/developer/authored/AccountTree.copy/resource_kind.py b/developer/authored/AccountTree.copy/resource_kind.py
new file mode 100644 (file)
index 0000000..a0a9abb
--- /dev/null
@@ -0,0 +1,80 @@
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+resource_kind - the kinds of resource a user can be allocated
+
+Each resource kind is listed once here. The window builds its Allocate
+panel from this list, so a kind that is listed but has no allocator yet
+appears greyed out. Writing an allocator for a kind is the step that turns
+its button on: set allocator to the function that opens its window.
+"""
+
+from dataclasses import dataclass
+from typing import Callable ,Optional
+
+
+@dataclass
+class ResourceKind:
+  name: str
+  group: str
+  description: str
+  allocator: Optional[Callable] = None
+
+  @property
+  def implemented(self) -> bool:
+    return self.allocator is not None
+
+
+# Groups in the order the window shows them.
+GROUP_ORDER = ["account" ,"storage" ,"network" ,"device" ,"privilege"]
+
+GROUP_TITLE = {
+  "account": "Accounts",
+  "storage": "Storage",
+  "network": "Network",
+  "device": "Devices",
+  "privilege": "Privileges",
+}
+
+KIND_LIST = [
+  ResourceKind("SubU" ,"account",
+    "a new user owned by this user; it opens its own resource window"),
+  ResourceKind("capture user" ,"account",
+    "take an existing user on this system as a SubU; the authority decides whether this is allowed"),
+
+  ResourceKind("home directory" ,"storage",
+    "the user's own home, kept on a device and mounted at /home/<user>"),
+  ResourceKind("view" ,"storage",
+    "a mapped mount that shows another user's files as owned by this user"),
+  ResourceKind("storage device" ,"storage",
+    "a removable or encrypted device this user may open and mount"),
+
+  ResourceKind("network zone" ,"network",
+    "a network namespace of the user's own; all of the user's traffic goes through it"),
+  ResourceKind("WireGuard tunnel" ,"network",
+    "a tunnel from the network zone to a remote machine"),
+  ResourceKind("forwarded service" ,"network",
+    "a local service (DNS, mail, ...) carried to the remote end of the tunnel, so it does not leak"),
+
+  ResourceKind("display" ,"device",
+    "permission to open windows on the MasU's display"),
+  ResourceKind("sound card" ,"device",
+    "the lease on the sound card; one user holds it at a time (subu_sound does this today)"),
+  ResourceKind("graphics chip" ,"device",
+    "use of the GPU, or of a virtual slice of it"),
+  ResourceKind("USB device" ,"device",
+    "a particular USB device, by vendor, product and serial"),
+  ResourceKind("udev device class" ,"device",
+    "visibility of a class of devices, such as cameras or serial ports"),
+
+  ResourceKind("sudo" ,"privilege",
+    "permission to run commands as root, possibly limited to a list"),
+  ResourceKind("mount" ,"privilege",
+    "permission to mount file systems"),
+]
+
+
+def kind_list_by_group():
+  result = []
+  for group in GROUP_ORDER:
+    result.append((group ,[k for k in KIND_LIST if k.group == group]))
+  return result
diff --git a/developer/authored/AccountTree.copy/same_user.py b/developer/authored/AccountTree.copy/same_user.py
new file mode 100644 (file)
index 0000000..ab85b6e
--- /dev/null
@@ -0,0 +1,256 @@
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+same_user - the work functions that say which users are the same user
+
+A user specifier names a user in one place:
+
+  system <host name> <account>    an account on this system
+  storage <SubU dir> <user>       a user of the storage database in that SubU dir
+
+is-same-user is given two user specifiers. In 5.0 one names a system account
+and the other a storage user; the correspondence is written into the system
+map of the system database, which is the authority for which storage users
+have accounts on this system. The storage database gets a note only.
+"""
+
+import os ,pwd ,grp
+from pathlib import Path
+
+import database
+from database import Database ,DatabaseError
+from common import (HOME ,run ,host_name ,account ,account_by_system_uid ,group ,uid_min ,is_mountpoint,
+  session_system_uids ,valid_name ,subu_dir_problem ,delete_mount_point)
+from system import open_system_database
+
+
+# --------------------------------------------------------------------------------
+# opening the two sides
+
+def open_sides(system_spec ,storage_spec ,log):
+  """
+  Check the two user specifiers and open both databases. Returns
+  (system_db ,storage_db) or None with errors logged.
+  """
+  if system_spec["host"] != host_name():
+    log.error(f"system {system_spec['host']}" ,f"this system is {host_name()}; only its own accounts can be named")
+    return None
+  system_db = open_system_database(log)
+  if system_db is None: return None
+  problem = subu_dir_problem(storage_spec["subu_dir"])
+  if problem:
+    log.error(storage_spec["subu_dir"] ,problem); return None
+  storage_db = Database.open(database.storage_database_path(storage_spec["subu_dir"]) ,"storage")
+  return system_db ,storage_db
+
+def mount_target(row):
+  """
+  Where the home of the account in a system map row is mounted: the account's
+  home in /etc/passwd, which SubU requires to be /home/<account>.
+  """
+  entry = account_by_system_uid(row["system_uid"])
+  return HOME / row["account_name"] ,entry
+
+def is_mounted_from(target ,source):
+  """
+  Whether target is a mount of source: the root of a bind mount is the source
+  directory itself, so the two have the same device and inode.
+  """
+  try:
+    a ,b = os.stat(target) ,os.stat(source)
+  except OSError:
+    return False
+  return is_mountpoint(target) and (a.st_dev ,a.st_ino) == (b.st_dev ,b.st_ino)
+
+
+# --------------------------------------------------------------------------------
+# is-same-user
+
+def is_same_user(system_spec ,storage_spec ,make_account ,log ,preview):
+  opened = open_sides(system_spec ,storage_spec ,log)
+  if opened is None: return
+  system_db ,storage_db = opened
+  account_name ,user_name = system_spec["account"] ,storage_spec["user"]
+
+  user = storage_db.row("SELECT * FROM user WHERE name = ?" ,(user_name,))
+  if user is None:
+    log.error(user_name ,f"no such user in database {storage_db.short()}"); return
+  if user["state"] != "active":
+    log.error(user_name ,f"the user is {user['state']}"); return
+  database_uuid = storage_db.uuid()
+
+  existing = system_db.row("SELECT * FROM system_map WHERE database_uuid = ? AND database_uid = ? AND state = 'in use'",
+                           (database_uuid ,user["database_uid"]))
+  if existing:
+    if existing["account_name"] == account_name:
+      log.line("kept" ,f"{account_name} is {user_name}" ,"already the same user"); return
+    log.error(user_name ,f"already the same user as account {existing['account_name']}"); return
+
+  entry = account(account_name)
+  if entry is not None:
+    if make_account:
+      log.error(account_name ,"the account exists; --make-account is for an account that does not"); return
+    if entry.pw_uid < uid_min():
+      log.error(account_name ,f"system uid {entry.pw_uid} is below UID_MIN {uid_min()}, an account of the system's own services"); return
+    taken = system_db.row("SELECT * FROM system_map WHERE system_uid = ? AND state = 'in use'" ,(entry.pw_uid,))
+    if taken:
+      log.error(account_name ,f"already the same user as database uid {taken['database_uid']} of database {taken['database_uuid'][:8]}"); return
+    if entry.pw_dir != str(HOME / account_name):
+      log.error(account_name ,f"its home is {entry.pw_dir}; SubU mounts a home only at /home/<account>"); return
+    system_uid ,system_gid ,kind = entry.pw_uid ,entry.pw_gid ,"adopted"
+    log.line("adopted" ,account_name ,f"existing account, system uid {system_uid}")
+  else:
+    if not make_account:
+      log.error(account_name ,"no such account on this system; give --make-account to make it"); return
+    problem = valid_name(account_name)
+    if problem:
+      log.error(account_name ,problem); return
+    system_uid = free_system_uid(system_db)
+    if system_uid is None:
+      log.error(account_name ,"no free system uid in this system's range"); return
+    command = ["useradd" ,"--badname" ,"-M" ,"-d" ,str(HOME / account_name) ,"-s" ,"/bin/bash",
+               "-u" ,str(system_uid) ,"-U" ,account_name]
+    if not run(command ,log ,preview): return
+    system_gid = system_uid if preview else account(account_name).pw_gid
+    kind = "made"
+    log.line("made account" ,account_name ,f"system uid {system_uid}, gid {system_gid}")
+
+  with system_db.transaction(f"{account_name} is the same user as {user_name} of database {storage_db.short()}" ,preview) as t:
+    t.execute("INSERT INTO system_map(system_uid ,system_gid ,account_name ,database_uuid ,database_uid ,account ,state ,mapped)"
+              " VALUES (? ,? ,? ,? ,? ,? ,'in use' ,?)",
+              (system_uid ,system_gid ,account_name ,database_uuid ,user["database_uid"] ,kind ,database.now()))
+  log.line("mapped" ,f"{account_name} is {user_name}",
+           f"system uid {system_uid} is database uid {user['database_uid']} of database {storage_db.short()}")
+
+  system_uuid = system_db.uuid()
+  if not storage_db.row("SELECT 1 FROM system_note WHERE system_uuid = ?" ,(system_uuid,)):
+    with storage_db.transaction(f"note system {system_uuid[:8]}" ,preview) as t:
+      t.execute("INSERT INTO system_note(system_uuid ,noted) VALUES (? ,?)" ,(system_uuid ,database.now()))
+    log.line("recorded" ,f"note of system {system_uuid[:8]}" ,"in the storage database, for its owner")
+
+def free_system_uid(system_db):
+  """
+  The lowest system uid in the range that no account and no group has, and
+  that the system map has never held, in use or retired.
+  """
+  r = system_db.row("SELECT * FROM system_uid_range")
+  held = {m["system_uid"] for m in system_db.rows("SELECT system_uid FROM system_map")}
+  for c in range(r["first"] ,r["last"] + 1):
+    if c in held or account_by_system_uid(c) is not None: continue
+    try:
+      grp.getgrgid(c); continue
+    except KeyError:
+      return c
+  return None
+
+
+# --------------------------------------------------------------------------------
+# is-not-same-user
+
+def is_not_same_user(system_spec ,storage_spec ,log ,preview):
+  opened = open_sides(system_spec ,storage_spec ,log)
+  if opened is None: return
+  system_db ,storage_db = opened
+  account_name ,user_name = system_spec["account"] ,storage_spec["user"]
+  user = storage_db.row("SELECT * FROM user WHERE name = ?" ,(user_name,))
+  if user is None:
+    log.error(user_name ,f"no such user in database {storage_db.short()}"); return
+  row = system_db.row("SELECT * FROM system_map WHERE database_uuid = ? AND database_uid = ? AND state = 'in use'",
+                      (storage_db.uuid() ,user["database_uid"]))
+  if row is None:
+    log.line("kept" ,user_name ,"not the same user as any account here; nothing to undo"); return
+  if row["account_name"] != account_name:
+    log.error(user_name ,f"is the same user as account {row['account_name']}, not {account_name}"); return
+  if account_by_system_uid(row["system_uid"]) and row["system_uid"] in session_system_uids():
+    log.error(account_name ,"is logged in; nothing was changed"); return
+  end_same_user(row ,system_db ,storage_spec["subu_dir"] ,user_name ,log ,preview)
+
+def end_same_user(row ,system_db ,subu_dir ,user_name ,log ,preview):
+  """
+  Undo one row of the system map: unmount the home if it is mounted from this
+  storage, delete the mount point attach made, delete the account if SubU
+  made it, and retire the row. Returns False when it had to stop.
+  """
+  target ,entry = mount_target(row)
+  source = database.user_data_dir(subu_dir) / user_name
+  if is_mounted_from(target ,source):
+    if not run(["umount" ,str(target)] ,log ,preview):
+      log.line("stopped" ,row["account_name"] ,"the home could not be unmounted; nothing further was changed")
+      return False
+    log.line("unmounted" ,str(target))
+  delete_mount_point(target ,log ,preview)
+  if row["account"] == "made" and entry is not None and entry.pw_name == row["account_name"]:
+    group_entry = group(row["account_name"])
+    if not run(["userdel" ,row["account_name"]] ,log ,preview): return False
+    log.line("deleted account" ,row["account_name"] ,f"system uid {row['system_uid']}")
+    if group_entry is not None and group_entry.gr_gid == row["system_gid"] and not group_entry.gr_mem:
+      if not preview and group(row["account_name"]) is not None:
+        if not run(["groupdel" ,row["account_name"]] ,log ,preview): return False
+      log.line("deleted group" ,row["account_name"] ,f"gid {row['system_gid']}")
+  elif row["account"] == "adopted":
+    log.line("kept" ,f"account {row['account_name']}" ,"it existed before SubU adopted it")
+  with system_db.transaction(f"{row['account_name']} is no longer the same user as database uid {row['database_uid']}"
+                             f" of database {row['database_uuid'][:8]}" ,preview) as t:
+    t.execute("UPDATE system_map SET state = 'retired' ,retired = ? WHERE id = ?" ,(database.now() ,row["id"]))
+  log.line("retired" ,f"system uid {row['system_uid']}" ,"in the system map; it is not given out again")
+  return True
+
+
+# --------------------------------------------------------------------------------
+# show and suggest
+
+def same_user_show(storage_spec):
+  """
+  The accounts of this system that are the same users as storage users; with
+  a storage, its users that are not, and the person accounts that are not.
+  """
+  system_db = Database.open(database.SYSTEM_DATABASE_PATH ,"system")
+  storage_db = None
+  if storage_spec:
+    storage_db = Database.open(database.storage_database_path(storage_spec["subu_dir"]) ,"storage")
+  in_use = system_db.rows("SELECT * FROM system_map WHERE state = 'in use' ORDER BY account_name")
+  if storage_db:
+    in_use = [m for m in in_use if m["database_uuid"] == storage_db.uuid()]
+  print(f"same users on system {host_name()}")
+  for m in in_use:
+    user_name = "?"
+    if storage_db:
+      u = storage_db.row("SELECT name FROM user WHERE database_uid = ?" ,(m["database_uid"],))
+      user_name = u["name"] if u else "(no such user)"
+    print(f"  system {host_name()} {m['account_name']:<24} storage {m['database_uuid'][:8]}"
+          f" {user_name if storage_db else 'database uid ' + str(m['database_uid'])}  ({m['account']})")
+  if storage_db:
+    mapped = {m["database_uid"] for m in in_use}
+    print(f"users of database {storage_db.short()} that are not the same user as any account here")
+    for u in storage_db.rows("SELECT * FROM user WHERE state = 'active' ORDER BY name"):
+      if u["database_uid"] not in mapped:
+        print(f"  {u['name']}")
+  mapped_account = {m["account_name"] for m in system_db.rows("SELECT account_name FROM system_map WHERE state = 'in use'")}
+  print("person accounts here that are not the same user as any storage user")
+  for entry in sorted(pwd.getpwall() ,key=lambda e: e.pw_name):
+    if uid_min() <= entry.pw_uid < 60000 and entry.pw_name not in mapped_account:
+      print(f"  {entry.pw_name}")
+  return 0
+
+def same_user_suggest(storage_spec):
+  """
+  Print is-same-user commands for the storage users that are not yet the same
+  user as any account, proposed by matching names. Changes nothing.
+  """
+  system_db = Database.open(database.SYSTEM_DATABASE_PATH ,"system")
+  subu_dir = storage_spec["subu_dir"]
+  storage_db = Database.open(database.storage_database_path(subu_dir) ,"storage")
+  mapped = {m["database_uid"] for m in system_db.rows(
+    "SELECT database_uid FROM system_map WHERE state = 'in use' AND database_uuid = ?" ,(storage_db.uuid(),))}
+  taken = {m["system_uid"] for m in system_db.rows("SELECT system_uid FROM system_map WHERE state = 'in use'")}
+  print("# proposed by matching names; read each line before running it")
+  for u in storage_db.rows("SELECT * FROM user WHERE state = 'active' ORDER BY name"):
+    if u["database_uid"] in mapped: continue
+    entry = account(u["name"])
+    line = f"SubU_admin is-same-user system {host_name()} '{u['name']}' storage {subu_dir} '{u['name']}'"
+    if entry is None:
+      print(line + " --make-account")
+    elif entry.pw_uid in taken or entry.pw_uid < uid_min():
+      print(f"# {u['name']}: the account of that name cannot be adopted (system uid {entry.pw_uid})")
+    else:
+      print(line + f"    # adopts the existing account, system uid {entry.pw_uid}")
+  return 0
diff --git a/developer/authored/AccountTree.copy/schema_storage.sql b/developer/authored/AccountTree.copy/schema_storage.sql
new file mode 100644 (file)
index 0000000..037de86
--- /dev/null
@@ -0,0 +1,79 @@
+-- schema_storage.sql
+--
+-- The storage database, standard 5.0, at <SubU dir>/database.sqlite3, where the
+-- SubU dir is <storage root>/home/SubU. It records the users kept on the
+-- storage. It grants nothing on any system: which of its users have accounts
+-- on a system is recorded by that system, in its system database.
+--
+--   database uuid   names this database; a copy of the database carries it too
+--   database uid    a user's uid in this database's own numbering, which counts
+--                   down from 1999999999; the user's files on the storage carry it
+--
+-- A database has no name for people: a name would travel with every copy of
+-- the database and come to mislead. It is shown by its database uuid, series
+-- number, head hash and the time of its last change.
+-- Times are for people to read; the series number in identity is authoritative.
+
+-- This database: its database uuid and the head of its hash chain. One row.
+CREATE TABLE identity (
+  database_uuid  TEXT NOT NULL,
+  series         INTEGER NOT NULL,      -- series number of the last transaction
+  head_hash      TEXT NOT NULL,         -- hash of the last transaction
+  created        TEXT NOT NULL
+);
+
+-- The SubU standards this database follows.
+CREATE TABLE standard (
+  version  TEXT PRIMARY KEY
+);
+
+-- The users this database carries.
+CREATE TABLE user (
+  database_uid       INTEGER PRIMARY KEY,
+  name               TEXT NOT NULL UNIQUE,  -- user name in this database
+  masu_database_uid  INTEGER,               -- database uid of its MasU; NULL at the top of a tree, or when its MasU is carried by another database
+  masu_name          TEXT,                  -- user name of its MasU
+  state              TEXT NOT NULL CHECK (state IN ('active' ,'deprecated' ,'deallocated')),
+  created            TEXT NOT NULL,
+  changed            TEXT NOT NULL
+);
+
+-- Every database uid this database has given out. None is given out twice.
+CREATE TABLE database_uid (
+  database_uid  INTEGER PRIMARY KEY,
+  state         TEXT NOT NULL CHECK (state IN ('in use' ,'retired'))
+);
+
+-- Systems on which a user of this database has been made the same user as an
+-- account. A note for the owner of the storage, never authority: the system
+-- database of each system is the record of that.
+CREATE TABLE system_note (
+  system_uuid  TEXT PRIMARY KEY,
+  noted        TEXT NOT NULL
+);
+
+-- Other databases this one knows, and the users of each that are the same
+-- users as users here. Not used in 5.0.
+CREATE TABLE acquaintance_database (
+  database_uuid  TEXT PRIMARY KEY,
+  introduced     TEXT NOT NULL
+);
+
+CREATE TABLE same_user_database (
+  other_database_uuid  TEXT NOT NULL,
+  other_database_uid   INTEGER NOT NULL,
+  database_uid         INTEGER NOT NULL,
+  PRIMARY KEY (other_database_uuid ,other_database_uid),
+  UNIQUE (other_database_uuid ,database_uid)
+);
+
+-- Every change. hash covers previous_hash, series, description, change and nonce.
+CREATE TABLE transaction_log (
+  series         INTEGER PRIMARY KEY,
+  previous_hash  TEXT NOT NULL,
+  nonce          TEXT NOT NULL,
+  description    TEXT NOT NULL,
+  change         TEXT NOT NULL,     -- JSON list of [statement ,parameters]
+  hash           TEXT NOT NULL,
+  time           TEXT NOT NULL
+);
diff --git a/developer/authored/AccountTree.copy/schema_system.sql b/developer/authored/AccountTree.copy/schema_system.sql
new file mode 100644 (file)
index 0000000..aa82f4c
--- /dev/null
@@ -0,0 +1,71 @@
+-- schema_system.sql
+--
+-- The system database, standard 5.0, at /var/lib/SubU/system.sqlite3. It is
+-- the system's own record, and the authority for which users of storage
+-- databases have accounts on this system. It is kept under /var, which holds
+-- state, not under /etc, which holds configuration: a system whose root file
+-- system is rolled back from a snapshot keeps /var as it was, so the record
+-- of the system uids already given out is not rolled back with /etc/passwd.
+--
+--   system uuid     names this system
+--   system uid      an account's uid on this system, in /etc/passwd
+--   database uuid   names a storage database
+--   database uid    a user's uid in that storage database's own numbering
+-- Times are for people to read; the series number in identity is authoritative.
+
+-- This system: its system uuid and the head of its hash chain. One row.
+CREATE TABLE identity (
+  system_uuid  TEXT NOT NULL,
+  series       INTEGER NOT NULL,
+  head_hash    TEXT NOT NULL,
+  created      TEXT NOT NULL
+);
+
+-- The SubU standards this database follows.
+CREATE TABLE standard (
+  version  TEXT PRIMARY KEY
+);
+
+-- The range of system uids SubU gives out when it makes an account. One row.
+CREATE TABLE system_uid_range (
+  first  INTEGER NOT NULL,
+  last   INTEGER NOT NULL
+);
+
+-- The system map: each account that is the same user as a user of a storage
+-- database. A row is never deleted; when the two stop being the same user it
+-- is retired. A system uid that appears in any row, in use or retired, is
+-- never given to an account SubU makes. An account adopted by SubU, one that
+-- existed before, may be mapped again after its row is retired.
+CREATE TABLE system_map (
+  id             INTEGER PRIMARY KEY,
+  system_uid     INTEGER NOT NULL,
+  system_gid     INTEGER NOT NULL,
+  account_name   TEXT NOT NULL,         -- kept so that an account lost from /etc/passwd can be made again
+  database_uuid  TEXT NOT NULL,
+  database_uid   INTEGER NOT NULL,
+  account        TEXT NOT NULL CHECK (account IN ('made' ,'adopted')),
+  state          TEXT NOT NULL CHECK (state IN ('in use' ,'retired')),
+  mapped         TEXT NOT NULL,
+  retired        TEXT
+);
+CREATE UNIQUE INDEX system_map_system_uid_in_use ON system_map(system_uid) WHERE state = 'in use';
+CREATE UNIQUE INDEX system_map_database_user_in_use ON system_map(database_uuid ,database_uid) WHERE state = 'in use';
+
+-- How many SubUs each account may have below it. An account with no row may
+-- have none.
+CREATE TABLE user_limit (
+  system_uid  INTEGER PRIMARY KEY,
+  maximum     INTEGER NOT NULL
+);
+
+-- Every change. hash covers previous_hash, series, description, change and nonce.
+CREATE TABLE transaction_log (
+  series         INTEGER PRIMARY KEY,
+  previous_hash  TEXT NOT NULL,
+  nonce          TEXT NOT NULL,
+  description    TEXT NOT NULL,
+  change         TEXT NOT NULL,
+  hash           TEXT NOT NULL,
+  time           TEXT NOT NULL
+);
diff --git a/developer/authored/AccountTree.copy/storage.py b/developer/authored/AccountTree.copy/storage.py
new file mode 100644 (file)
index 0000000..6326772
--- /dev/null
@@ -0,0 +1,304 @@
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+storage - the work functions for storage databases
+
+A storage is a persistent storage device that keeps users' data: the disk
+inside the machine, or a drive plugged in. Its SubU dir is <storage root>/home/SubU,
+which holds the storage database and user_data, the users' homes. On the disk a
+system runs from, the SubU dir is /home/SubU.
+
+Which storage users have accounts on this system is recorded in the system
+map of the system database, and attach mounts exactly the users it lists.
+"""
+
+import os ,shutil ,subprocess
+from pathlib import Path
+
+import database
+from database import Database ,DatabaseError
+from common import (HOME ,SEPARATOR ,run ,account_by_system_uid ,is_mountpoint ,mount_point_list,
+  session_system_uids ,valid_name ,subu_dir_problem)
+from system import open_system_database ,DATABASE_UID_FLOOR
+import same_user
+
+# A storage database gives out database uids counting down from
+# DATABASE_UID_FIRST, while systems give out system uids counting up from low
+# numbers. See "Limits of uids" in the design document.
+DATABASE_UID_FIRST = 1_999_999_999
+
+
+def open_storage_database(subu_dir ,log):
+  problem = subu_dir_problem(subu_dir)
+  if problem:
+    log.error(str(subu_dir) ,problem); return None
+  return Database.open(database.storage_database_path(subu_dir) ,"storage")
+
+def system_map_in_use(system_db ,storage_db):
+  return system_db.rows("SELECT * FROM system_map WHERE database_uuid = ? AND state = 'in use' ORDER BY account_name",
+                        (storage_db.uuid(),))
+
+
+# --------------------------------------------------------------------------------
+# storage make, show, delete
+
+def storage_make(subu_dir ,log ,preview):
+  problem = subu_dir_problem(subu_dir)
+  if problem:
+    log.error(str(subu_dir) ,problem); return
+  subu_dir = Path(subu_dir)
+  home = subu_dir.parent
+  root = home.parent
+  if not root.is_dir():
+    log.error(str(root) ,"the storage root is not a directory"); return
+  path = database.storage_database_path(subu_dir)
+  if path.exists():
+    db = Database.open(path ,"storage")
+    log.line("kept" ,str(path) ,f"a storage database exists, database uuid {db.uuid()}")
+  else:
+    for directory ,mode in ((home ,0o755) ,(subu_dir ,0o700)):
+      if not directory.is_dir():
+        if not preview:
+          directory.mkdir(mode=mode)
+          os.chmod(directory ,mode)
+        log.line("made" ,str(directory) ,f"mode {mode:04o}")
+    db = Database.make(path ,"storage" ,preview)
+    # a database uuid is drawn at random; the one drawn in a preview is thrown away
+    log.line("made" ,str(path) ,"a new database uuid" if preview else f"database uuid {db.uuid()}")
+  user_data = database.user_data_dir(subu_dir)
+  if user_data.is_dir():
+    log.line("kept" ,str(user_data))
+  else:
+    if not preview:
+      user_data.mkdir(mode=0o700)
+      os.chmod(user_data ,0o700)
+    log.line("made" ,str(user_data) ,"root only, mode 0700")
+
+def storage_show(subu_dir):
+  problem = subu_dir_problem(subu_dir)
+  if problem:
+    print(f"Error: {problem}"); return 1
+  db = Database.open(database.storage_database_path(subu_dir) ,"storage")
+  d = db.identity()
+  print(f"storage   database uuid {d['database_uuid']}")
+  print(f"file      {db.path}")
+  print(f"series    {d['series']}  head {d['head_hash'][:16]}")
+  print(f"made      {d['created']}")
+  print(f"changed   {db.last_change()}")
+  print("standard  " + " ".join(r["version"] for r in db.rows("SELECT version FROM standard")))
+  mapped = {}
+  if database.SYSTEM_DATABASE_PATH.is_file():
+    system_db = Database.open(database.SYSTEM_DATABASE_PATH ,"system")
+    mapped = {m["database_uid"]: m for m in system_map_in_use(system_db ,db)}
+  print("users")
+  for u in db.rows("SELECT * FROM user ORDER BY name"):
+    m = mapped.get(u["database_uid"])
+    here = f"account {m['account_name']}, system uid {m['system_uid']}" if m else "no account on this system"
+    print(f"  {u['name']:<28}database uid {u['database_uid']:<12}{u['state']:<10}{here}")
+  for n in db.rows("SELECT * FROM system_note ORDER BY noted"):
+    print(f"noted     system {n['system_uuid']}  {n['noted']}")
+  problem = db.verify()
+  print("verify    " + ("ok" if not problem else "; ".join(problem)))
+  return 1 if problem else 0
+
+def storage_delete(subu_dir ,user_data_too ,forget_other_systems ,log ,preview):
+  """
+  Undo on this system what SubU did for the storage database in subu_dir,
+  then delete the SubU dir. Every check is made before anything is changed;
+  a check that fails stops the command with nothing changed.
+  """
+  problem = subu_dir_problem(subu_dir)
+  if problem:
+    log.error(str(subu_dir) ,problem); return
+  subu_dir = Path(subu_dir)
+  user_data = database.user_data_dir(subu_dir)
+  path = database.storage_database_path(subu_dir)
+  if not path.is_file():
+    log.line("kept" ,str(subu_dir) ,"no storage database here; nothing to delete"); return
+  db = Database.open(path ,"storage")
+  system_db = Database.open(database.SYSTEM_DATABASE_PATH ,"system") if database.SYSTEM_DATABASE_PATH.is_file() else None
+  this_system_uuid = system_db.uuid() if system_db else None
+  in_use = system_map_in_use(system_db ,db) if system_db else []
+  user_name = {u["database_uid"]: u["name"] for u in db.rows("SELECT database_uid ,name FROM user")}
+
+  # ---- checks
+  problem = []
+  if not forget_other_systems:
+    for n in db.rows("SELECT * FROM system_note WHERE system_uuid IS NOT ?" ,(this_system_uuid,)):
+      problem.append(f"system {n['system_uuid'][:8]} has accounts for users of this database, and its system map"
+                     " would name a database that no longer exists; give --forget-other-systems to delete anyway")
+  in_session = session_system_uids()
+  for m in in_use:
+    if m["system_uid"] in in_session:
+      problem.append(f"{m['account_name']} is logged in")
+  for target in mount_point_list():
+    if target.startswith(str(subu_dir) + "/"):
+      problem.append(f"{target} is a mount point inside {subu_dir}")
+  for entry in sorted(subu_dir.iterdir()):
+    if entry.name != "user_data" and not entry.name.startswith("database.sqlite3"):
+      problem.append(f"{entry} is not SubU's")
+  holding_file = []
+  if user_data.is_dir():
+    for entry in sorted(user_data.iterdir()):
+      if entry.name not in user_name.values():
+        problem.append(f"{entry} is not the home of a user of this database")
+      elif entry.is_dir() and any(entry.iterdir()):
+        holding_file.append(entry.name)
+  if holding_file and not user_data_too:
+    problem.append("these homes hold files: " + ", ".join(holding_file) + "; give --user-data to delete them too")
+  if problem:
+    for p in problem: log.error(str(subu_dir) ,p)
+    log.line("kept" ,str(subu_dir) ,"nothing was changed")
+    return
+
+  # ---- this system's accounts for its users
+  for m in in_use:
+    if not same_user.end_same_user(m ,system_db ,subu_dir ,user_name.get(m["database_uid"] ,"?") ,log ,preview):
+      log.line("stopped" ,str(subu_dir) ,"nothing further was changed"); return
+
+  # ---- files
+  if user_data.is_dir():
+    home_count = sum(1 for _ in user_data.iterdir())
+    if not preview:
+      shutil.rmtree(user_data)
+    log.line("deleted" ,str(user_data) ,f"{home_count} user home" + ("" if home_count == 1 else "s"))
+  short = db.short()
+  db.close()
+  for entry in sorted(subu_dir.iterdir()):
+    if entry.name.startswith("database.sqlite3"):
+      if not preview: entry.unlink()
+      log.line("deleted" ,str(entry) ,f"database {short}" if entry.name == "database.sqlite3" else None)
+  if not preview:
+    subu_dir.rmdir()
+  log.line("deleted" ,str(subu_dir))
+
+
+# --------------------------------------------------------------------------------
+# user make
+
+def user_make(subu_dir ,name ,database_uid ,masu ,log ,preview):
+  problem = valid_name(name)
+  if problem:
+    log.error(name ,problem); return
+  db = open_storage_database(subu_dir ,log)
+  if db is None: return
+  if db.row("SELECT 1 FROM user WHERE name = ?" ,(name,)):
+    log.line("kept" ,f"user {name}" ,"already a user of this database"); return
+  if masu is None and SEPARATOR in name:
+    masu = name.rsplit(SEPARATOR ,1)[0]
+  if masu is not None and not name.startswith(masu + SEPARATOR):
+    log.error(name ,f"the name of a SubU of {masu} begins with '{masu}{SEPARATOR}'"); return
+  masu_row = db.row("SELECT database_uid FROM user WHERE name = ?" ,(masu,)) if masu else None
+  masu_database_uid = masu_row["database_uid"] if masu_row else None
+
+  if database_uid is None:
+    # the next database uid down from the lowest given out in the band; one
+    # kept with --uid lies outside the band and does not move the count
+    lowest = db.row("SELECT MIN(database_uid) AS m FROM database_uid WHERE database_uid >= ?",
+                    (DATABASE_UID_FLOOR,))["m"]
+    database_uid = DATABASE_UID_FIRST if lowest is None else lowest - 1
+    if database_uid < DATABASE_UID_FLOOR:
+      log.error(name ,"this database has given out every database uid in its band"); return
+  elif db.row("SELECT 1 FROM database_uid WHERE database_uid = ?" ,(database_uid,)):
+    log.error(name ,f"database uid {database_uid} has been given out before"); return
+
+  home = database.user_data_dir(subu_dir) / name
+  if home.exists() and home.stat().st_uid != database_uid:
+    log.error(str(home) ,f"exists, owned by uid {home.stat().st_uid}, not database uid {database_uid}"); return
+
+  stamp = database.now()
+  with db.transaction(f"make user {name}" ,preview) as t:
+    t.execute("INSERT INTO database_uid(database_uid ,state) VALUES (? ,'in use')" ,(database_uid,))
+    t.execute("INSERT INTO user(database_uid ,name ,masu_database_uid ,masu_name ,state ,created ,changed)"
+      " VALUES (? ,? ,? ,? ,'active' ,? ,?)" ,(database_uid ,name ,masu_database_uid ,masu ,stamp ,stamp))
+  log.line("made" ,f"user {name}" ,f"database uid {database_uid}" + (f", SubU of {masu}" if masu else ""))
+  if home.exists():
+    log.line("kept" ,str(home) ,"existing home directory, owned by its database uid")
+  else:
+    if not preview:
+      home.mkdir(mode=0o700)
+      os.chown(home ,database_uid ,database_uid)
+      os.chmod(home ,0o700)
+    log.line("made" ,str(home) ,f"owned by database uid {database_uid}, mode 0700")
+
+
+# --------------------------------------------------------------------------------
+# attach and detach
+
+def attach(subu_dir ,log ,preview):
+  """
+  Mount the home of each storage user that the system map makes the same
+  user as an account here, at /home/<account>.
+  """
+  system_db = open_system_database(log)
+  if system_db is None: return
+  db = open_storage_database(subu_dir ,log)
+  if db is None: return
+  problem = db.verify()
+  if problem:
+    for p in problem: log.error(str(db.path) ,p)
+    return
+  log.line("found" ,f"database {db.short()}" ,f"series {db.identity()['series']}, chain verified")
+  in_use = system_map_in_use(system_db ,db)
+  mapped = {m["database_uid"] for m in in_use}
+  for m in in_use:
+    attach_user(subu_dir ,db ,m ,log ,preview)
+  for u in db.rows("SELECT * FROM user WHERE state = 'active' ORDER BY name"):
+    if u["database_uid"] not in mapped:
+      log.line("skipped" ,f"user {u['name']}" ,"not the same user as any account on this system")
+
+def attach_user(subu_dir ,db ,m ,log ,preview):
+  user = db.row("SELECT * FROM user WHERE database_uid = ?" ,(m["database_uid"],))
+  if user is None or user["state"] != "active":
+    log.error(m["account_name"] ,f"the system map names database uid {m['database_uid']},"
+              " which is not an active user of this database"); return
+  target ,entry = same_user.mount_target(m)
+  if entry is None or entry.pw_name != m["account_name"]:
+    log.error(m["account_name"] ,f"no account {m['account_name']} with system uid {m['system_uid']}; it is to be made again"); return
+  if entry.pw_dir != str(target):
+    log.error(m["account_name"] ,f"its home is {entry.pw_dir}; SubU mounts a home only at {target}"); return
+  source = database.user_data_dir(subu_dir) / user["name"]
+  if not source.is_dir():
+    log.error(str(source) ,"no such directory"); return
+  if is_mountpoint(target):
+    if same_user.is_mounted_from(target ,source):
+      log.line("kept" ,str(target) ,"already mounted"); return
+    log.error(str(target) ,"something else is mounted there"); return
+  if target.exists():
+    if not target.is_dir():
+      log.error(str(target) ,"exists and is not a directory"); return
+    if any(target.iterdir()):
+      log.error(str(target) ,"not empty and not a mount point; nothing is mounted over it"); return
+  else:
+    if not preview:
+      target.mkdir(mode=0o000)
+      os.chmod(target ,0o000)
+      subprocess.run(["chattr" ,"+i" ,str(target)] ,capture_output=True)
+    log.line("made" ,str(target) ,"mount point, root, mode 0000, immutable")
+
+  # nosuid and nodev: a storage may carry set-user-id programs and device files
+  database_uid = m["database_uid"]
+  if database_uid == m["system_uid"] and database_uid == m["system_gid"]:
+    option = "nosuid,nodev"
+    how = "bind mount, the database uid and system uid are the same"
+  else:
+    option = f"X-mount.idmap=u:{database_uid}:{m['system_uid']}:1 g:{database_uid}:{m['system_gid']}:1,nosuid,nodev"
+    how = f"idmapped, database uid {database_uid} to system uid {m['system_uid']}"
+  if run(["mount" ,"--bind" ,"-o" ,option ,str(source) ,str(target)] ,log ,preview):
+    log.line("mounted" ,str(target) ,f"{how}; nosuid, nodev")
+
+def detach(subu_dir ,log ,preview):
+  system_db = open_system_database(log)
+  if system_db is None: return
+  db = open_storage_database(subu_dir ,log)
+  if db is None: return
+  in_session = session_system_uids()
+  for m in system_map_in_use(system_db ,db):
+    user = db.row("SELECT name FROM user WHERE database_uid = ?" ,(m["database_uid"],))
+    target ,entry = same_user.mount_target(m)
+    source = database.user_data_dir(subu_dir) / (user["name"] if user else "")
+    if not same_user.is_mounted_from(target ,source):
+      log.line("kept" ,str(target) ,"not mounted from this storage"); continue
+    if m["system_uid"] in in_session:
+      log.error(m["account_name"] ,"is logged in; not detached"); continue
+    if run(["umount" ,str(target)] ,log ,preview):
+      log.line("unmounted" ,str(target))
diff --git a/developer/authored/AccountTree.copy/system.py b/developer/authored/AccountTree.copy/system.py
new file mode 100644 (file)
index 0000000..ae89ba0
--- /dev/null
@@ -0,0 +1,131 @@
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+system - the work functions for the system database
+
+The system database, /var/lib/SubU/system.sqlite3, is this system's own record:
+its system uuid, the range of system uids SubU gives out, the system map of
+accounts that are the same users as users of storage databases, and the
+limits on how many SubUs each account may make.
+"""
+
+import os
+from pathlib import Path
+
+import database
+from database import Database ,DatabaseError
+from common import host_name ,account_by_system_uid ,account
+
+DEFAULT_SYSTEM_UID_FIRST = 2000
+DEFAULT_SYSTEM_UID_LAST = 59999
+# the lowest database uid, the floor of the band storage databases count down in
+DATABASE_UID_FLOOR = 1_879_048_192
+
+
+def open_system_database(log):
+  """
+  This system's database, or None with an error logged when there is none.
+  """
+  if not database.SYSTEM_DATABASE_PATH.is_file():
+    log.error("system" ,f"no system database at {database.SYSTEM_DATABASE_PATH}; run 'system make' first")
+    return None
+  return Database.open(database.SYSTEM_DATABASE_PATH ,"system")
+
+
+def system_make(system_uid_first ,system_uid_last ,log ,preview):
+  path = database.SYSTEM_DATABASE_PATH
+  if system_uid_last >= DATABASE_UID_FLOOR:
+    log.error("system uid range" ,f"the range reaches {DATABASE_UID_FLOOR}, where database uids begin"); return
+  if system_uid_first > system_uid_last:
+    log.error("system uid range" ,f"{system_uid_first} is above {system_uid_last}"); return
+  if path.exists():
+    db = Database.open(path ,"system")
+    log.line("kept" ,str(path) ,f"a system database exists, system uuid {db.uuid()}")
+    return
+  if not database.SYSTEM_DIR.is_dir():
+    if not preview:
+      database.SYSTEM_DIR.mkdir(mode=0o700 ,parents=True)
+      os.chmod(database.SYSTEM_DIR ,0o700)
+    log.line("made" ,str(database.SYSTEM_DIR) ,"root only, mode 0700")
+  db = Database.make(path ,"system" ,preview)
+  with db.transaction("set system uid range" ,preview) as t:
+    t.execute("INSERT INTO system_uid_range(first ,last) VALUES (? ,?)" ,(system_uid_first ,system_uid_last))
+  # a system uuid is drawn at random; the one drawn in a preview is thrown away
+  log.line("made" ,str(path) ,"a new system uuid" if preview else f"system uuid {db.uuid()}")
+  log.line("recorded" ,"system uid range" ,f"{system_uid_first} to {system_uid_last}")
+
+def system_show():
+  path = database.SYSTEM_DATABASE_PATH
+  db = Database.open(path ,"system")
+  d = db.identity()
+  r = db.row("SELECT * FROM system_uid_range")
+  print(f"system    {host_name()}  system uuid {d['system_uuid']}")
+  print(f"file      {path}")
+  print(f"series    {d['series']}  head {d['head_hash'][:16]}")
+  print(f"made      {d['created']}")
+  print(f"changed   {db.last_change()}")
+  print("standard  " + " ".join(s["version"] for s in db.rows("SELECT version FROM standard")))
+  print(f"range     system uids {r['first']} to {r['last']}")
+  print("system map")
+  for m in db.rows("SELECT * FROM system_map ORDER BY state ,system_uid"):
+    entry = account_by_system_uid(m["system_uid"])
+    present = "  (account missing)" if m["state"] == "in use" and not (entry and entry.pw_name == m["account_name"]) else ""
+    print(f"  {m['account_name']:<26}system uid {m['system_uid']:<7}{m['account']:<8}{m['state']:<8}"
+          f"database {m['database_uuid'][:8]} database uid {m['database_uid']}{present}")
+  limit_row = db.rows("SELECT * FROM user_limit ORDER BY system_uid")
+  if limit_row:
+    print("limits")
+    for l in limit_row:
+      entry = account_by_system_uid(l["system_uid"])
+      who = entry.pw_name if entry else f"system uid {l['system_uid']}"
+      print(f"  {who:<26}{l['maximum']}")
+  problem = db.verify()
+  print("verify    " + ("ok" if not problem else "; ".join(problem)))
+  return 1 if problem else 0
+
+def system_delete(log ,preview):
+  """
+  Delete the system database. Refused while its system map has any row in
+  use: those accounts would be left with no record, and their system uids
+  could be given out again.
+  """
+  path = database.SYSTEM_DATABASE_PATH
+  if not path.exists():
+    log.line("kept" ,str(path) ,"no system database here; nothing to delete"); return
+  db = Database.open(path ,"system")
+  in_use = db.rows("SELECT * FROM system_map WHERE state = 'in use' ORDER BY account_name")
+  if in_use:
+    for m in in_use:
+      log.error(str(path) ,f"{m['account_name']} is still the same user as database uid {m['database_uid']}"
+                f" of database {m['database_uuid'][:8]}; run is-not-same-user, or storage delete, first")
+    log.line("kept" ,str(path) ,"nothing was changed")
+    return
+  db.close()
+  for entry in sorted(path.parent.iterdir()):
+    if entry.name.startswith("system.sqlite3"):
+      if not preview: entry.unlink()
+      log.line("deleted" ,str(entry))
+  if not preview and not any(path.parent.iterdir()):
+    path.parent.rmdir()
+    log.line("deleted" ,str(path.parent))
+  elif preview:
+    log.line("deleted" ,str(path.parent) ,"when nothing else is in it")
+
+def limit_set(account_name ,maximum ,log ,preview):
+  """
+  Set how many SubUs an account may have below it on this system.
+  """
+  if maximum < 0:
+    log.error(account_name ,"a limit is 0 or more"); return
+  db = open_system_database(log)
+  if db is None: return
+  entry = account(account_name)
+  if entry is None:
+    log.error(account_name ,"no such account on this system"); return
+  old = db.row("SELECT maximum FROM user_limit WHERE system_uid = ?" ,(entry.pw_uid,))
+  if old and old["maximum"] == maximum:
+    log.line("kept" ,f"{account_name} limit {maximum}" ,"already set"); return
+  with db.transaction(f"set limit of {account_name} to {maximum}" ,preview) as t:
+    t.execute("INSERT INTO user_limit(system_uid ,maximum) VALUES (? ,?)"
+              " ON CONFLICT(system_uid) DO UPDATE SET maximum = excluded.maximum" ,(entry.pw_uid ,maximum))
+  was = f"was {old['maximum']}" if old else "was unset, which counts as 0"
+  log.line("set" ,f"{account_name} limit {maximum}" ,was)
diff --git a/developer/authored/AccountTree.copy/window.py b/developer/authored/AccountTree.copy/window.py
new file mode 100644 (file)
index 0000000..409c154
--- /dev/null
@@ -0,0 +1,338 @@
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+window - the SubU resource window
+
+One window shows the resources of one user, the window user, and acts with
+the authority of another, the acting user: the person who started the
+tool. The first window is for the acting user. Clicking a SubU opens that
+SubU's window, still acting with the first user's authority.
+
+The window is made of panels, each a framed area, so that every option is
+visible at once without menus. An option that has no implementation yet is
+shown greyed out. Pointing at any option shows what it does in a tooltip.
+
+The window is drawn with GTK 4, the toolkit of the desktop's own programs,
+so it opens on Wayland directly, or on X when that is what the session has.
+"""
+
+import account
+import authority
+import resource_kind
+
+GTK_MISSING = """\
+SubU window: GTK 4 for Python is not available to {python}
+  {error}
+This Python needs the PyGObject package and the GTK 4 introspection data.
+  System Python, openSUSE:  zypper install python313-gobject typelib-1_0-Gtk-4_0   (313 for Python 3.13)
+  Project Python:           zypper install gobject-introspection-devel cairo-devel typelib-1_0-Gtk-4_0
+                            python3 -m pip install PyGObject
+"""
+
+APPLICATION_ID = "com.ReasoningTechnology.SubU"
+PAD = 6
+STATE_REFRESH_SECONDS = 5
+
+
+def load_gtk():
+  # Draw with cairo, on the processor, and do not open OpenGL at all. A
+  # window of buttons gains nothing from the graphics chip, and a SubU is
+  # often not allowed to open it, which makes GTK print a warning for every
+  # attempt. GDK_DISABLE=gl is read by GTK 4.16 and later. Values already in
+  # the environment win.
+  import os
+  os.environ.setdefault("GSK_RENDERER" ,"cairo")
+  os.environ.setdefault("GDK_DISABLE" ,"gl")
+  import gi
+  gi.require_version("Gtk" ,"4.0")
+  from gi.repository import Gtk ,Gio ,GLib
+  return Gtk ,Gio ,GLib
+
+
+# Panel borders drawn in the theme's own text colour at partial strength, so
+# they stand out on a dark theme and a light one alike. The theme's default
+# frame border is faint on dark themes.
+PANEL_CSS = """
+frame.subu-panel > border {
+  border: 1px solid alpha(currentColor ,0.45);
+  border-radius: 6px;
+}
+frame.subu-panel > label {
+  font-weight: bold;
+}
+"""
+
+def install_css(Gtk):
+  from gi.repository import Gdk
+  provider = Gtk.CssProvider()
+  if hasattr(provider ,"load_from_string"):
+    provider.load_from_string(PANEL_CSS)
+  else:
+    provider.load_from_data(PANEL_CSS ,-1)
+  Gtk.StyleContext.add_provider_for_display(Gdk.Display.get_default() ,provider,
+    Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
+
+
+def build_window_class(Gtk ,GLib):
+
+  class PanelHelper:
+    """
+    The pieces both kinds of window are built from.
+    """
+
+    def panel(self ,title):
+      """
+      A framed panel and the box its contents go in.
+      """
+      frame = Gtk.Frame(label=title)
+      frame.add_css_class("subu-panel")
+      box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL ,spacing=PAD,
+        margin_top=PAD ,margin_bottom=PAD ,margin_start=PAD ,margin_end=PAD)
+      frame.set_child(box)
+      return frame ,box
+
+    def label(self ,text ,dim=False ,markup=False):
+      widget = Gtk.Label(xalign=0 ,wrap=True)
+      if markup:
+        widget.set_markup(text)
+      else:
+        widget.set_text(text)
+      if dim:
+        widget.add_css_class("dim-label")
+      return widget
+
+    def option(self ,text ,description ,action=None):
+      """
+      A clickable option. Without an action it is greyed out, and its
+      tooltip says it is not written yet.
+      """
+      button = Gtk.Button(label=text)
+      if action is None:
+        button.set_sensitive(False)
+        button.set_tooltip_text(f"{description}\n[not written yet]")
+      else:
+        button.set_tooltip_text(description)
+        button.connect("clicked" ,lambda b: action())
+      return button
+
+    def row(self ,widget_list):
+      box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL ,spacing=PAD)
+      for widget in widget_list:
+        widget.set_hexpand(True)
+        box.append(widget)
+      return box
+
+    def authority_line(self):
+      """
+      Says whose authority the window acts with, when it is not the window
+      user's own.
+      """
+      if self.acting_user.name == self.window_user.name:
+        return None
+      return self.label(
+        f"<i>acting with the authority of {escape(self.acting_user.name)}</i>" ,markup=True)
+
+
+  class AllocateWindow(PanelHelper ,Gtk.Window):
+    """
+    The window for allocating resources of one group, such as Storage, to
+    the window user. Each kind of resource in the group is a row: its
+    button, which will open that kind's allocator, and what it is. Options
+    a kind offers will be laid out here as allocators are written.
+    """
+
+    def __init__(self ,parent ,group ,window_user ,acting_user):
+      super().__init__(transient_for=parent)
+      self.window_user = window_user
+      self.acting_user = acting_user
+      title = resource_kind.GROUP_TITLE[group]
+      self.set_title(f"SubU: {window_user.name}: allocate {title}")
+
+      box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL ,spacing=PAD,
+        margin_top=PAD ,margin_bottom=PAD ,margin_start=PAD ,margin_end=PAD)
+      box.append(self.label(f"<b>{escape(window_user.name)}</b>" ,markup=True))
+      line = self.authority_line()
+      if line: box.append(line)
+
+      frame ,panel = self.panel(f"Allocate {title}")
+      grid = Gtk.Grid(column_spacing=PAD*2 ,row_spacing=PAD)
+      kind_list = dict(resource_kind.kind_list_by_group())[group]
+      for i ,kind in enumerate(kind_list):
+        action = None
+        if kind.implemented:
+          action = lambda k=kind: k.allocator(self.window_user ,self.acting_user)
+        grid.attach(self.option(kind.name ,kind.description ,action) ,0 ,i ,1 ,1)
+        text = kind.description if kind.implemented else f"{kind.description}  (not written yet)"
+        description = self.label(text ,dim=not kind.implemented)
+        description.set_hexpand(True)
+        grid.attach(description ,1 ,i ,1 ,1)
+      panel.append(grid)
+      box.append(frame)
+
+      close = Gtk.Button(label="Close" ,halign=Gtk.Align.END)
+      close.connect("clicked" ,lambda b: self.close())
+      box.append(close)
+      self.set_child(box)
+      self.set_default_size(560 ,-1)
+
+
+  class ResourceWindow(PanelHelper ,Gtk.ApplicationWindow):
+
+    def __init__(self ,application ,window_user ,acting_user):
+      super().__init__(application=application)
+      self.window_user = window_user
+      self.acting_user = acting_user
+      self.set_title(f"SubU: {window_user.name}")
+
+      grid = Gtk.Grid(column_spacing=PAD ,row_spacing=PAD,
+        margin_top=PAD ,margin_bottom=PAD ,margin_start=PAD ,margin_end=PAD)
+      grid.set_column_homogeneous(True)
+      #   row 0   User   Authority   Control
+      #   row 1   SubU   Allocate    Allocated
+      grid.attach(self.panel_identity() ,0 ,0 ,1 ,1)
+      grid.attach(self.panel_authority(),1 ,0 ,1 ,1)
+      grid.attach(self.panel_control()  ,2 ,0 ,1 ,1)
+      grid.attach(self.panel_subu()     ,0 ,1 ,1 ,1)
+      grid.attach(self.panel_allocate() ,1 ,1 ,1 ,1)
+      grid.attach(self.panel_allocated(),2 ,1 ,1 ,1)
+
+      # The panels can be taller than the screen allows a window to be. In a
+      # scrolled window they keep their natural size and the window can be
+      # any size, instead of GTK warning that the window is too short.
+      scroller = Gtk.ScrolledWindow(propagate_natural_height=True ,propagate_natural_width=True)
+      scroller.set_child(grid)
+      self.set_child(scroller)
+
+    # ----------------------------------------------------------------------------
+    # panels
+
+    def panel_identity(self):
+      frame ,box = self.panel("User")
+      name = self.window_user.name
+      masu = account.masu_of(name)
+      box.append(self.label(f"<big><b>{escape(name)}</b></big>" ,markup=True))
+      if masu:
+        relation = f"SubU of {masu}"
+      else:
+        relation = "the system's root user, MasU of every person account"
+      box.append(self.label(f"uid {self.window_user.uid}   {relation}"))
+      line = self.authority_line()
+      if line: box.append(line)
+      return frame
+
+    def panel_allocate(self):
+      """
+      One button per group of resources. Each opens the window for
+      allocating resources of that group.
+      """
+      frame ,box = self.panel("Allocate")
+      for group ,kind_list in resource_kind.kind_list_by_group():
+        button = Gtk.Button(label=resource_kind.GROUP_TITLE[group])
+        button.set_tooltip_text(", ".join(kind.name for kind in kind_list))
+        button.connect("clicked" ,lambda b ,g=group: self.open_allocate(g))
+        box.append(button)
+      return frame
+
+    def panel_subu(self):
+      """
+      One row per SubU of the window user: a button with its name, which
+      opens its window, and its state. The states are read again every few
+      seconds, so a login or logout shows without reopening the window.
+      """
+      frame ,box = self.panel("SubU")
+      subu_list = account.subu_list(self.window_user.name)
+      if not subu_list:
+        box.append(self.label("(none)" ,dim=True))
+        return frame
+      grid = Gtk.Grid(column_spacing=PAD*2 ,row_spacing=PAD)
+      self.subu_state_label = []
+      session_uids = account.session_uid_set()
+      for i ,subu in enumerate(subu_list):
+        button = Gtk.Button(label=account.short_name(subu.name))
+        button.get_child().set_xalign(0)
+        button.set_hexpand(True)
+        button.set_tooltip_text(
+          f"open the resource window of {subu.name}, "
+          f"acting with the authority of {self.acting_user.name}")
+        button.connect("clicked" ,lambda b ,s=subu: self.open_subu(s))
+        grid.attach(button ,0 ,i ,1 ,1)
+        state = self.label(account.state(subu ,session_uids))
+        grid.attach(state ,1 ,i ,1 ,1)
+        self.subu_state_label.append((subu ,state))
+      box.append(grid)
+      GLib.timeout_add_seconds(STATE_REFRESH_SECONDS ,self.refresh_subu_state)
+      return frame
+
+    def refresh_subu_state(self):
+      session_uids = account.session_uid_set()
+      for subu ,label in self.subu_state_label:
+        label.set_text(account.state(subu ,session_uids))
+      return self.get_visible()       # False stops the timer once the window is closed
+
+    def panel_allocated(self):
+      frame ,box = self.panel("Allocated")
+      user = self.window_user
+      state = "present" if user.home_exists else "not found"
+      box.append(self.label(f"home directory   {user.home}   ({state})"))
+      box.append(self.label("other resources: there is no resource database yet" ,dim=True))
+      return frame
+
+    def panel_control(self):
+      """
+      What can be done to the window user as a whole: its sessions, and
+      its account's life.
+      """
+      frame ,box = self.panel("Control")
+      box.append(self.row([
+        self.option("login",
+          "open a shell or desktop session as this user (subu_login does this today)"),
+        self.option("logout" ,"end every session and user service of this user"),
+        self.option("take sound",
+          "give this user the sound card lease (subu_sound does this today)"),
+      ]))
+      box.append(self.row([
+        self.option("rename" ,"give this user a new name; its uid and files stay"),
+        self.option("deprecate" ,"lock this user and end its sessions; its data and name are kept"),
+        self.option("deallocate",
+          "remove this user once deprecated; its data is archived or destroyed and its uid retired"),
+      ]))
+      return frame
+
+    def panel_authority(self):
+      frame ,box = self.panel("Authority")
+      acting = self.acting_user
+      box.append(self.label(f"acting user   {acting.name}   (uid {acting.uid})"))
+      decision = authority.request(acting.name ,"view" ,self.window_user.name)
+      box.append(self.label(f"authority   {decision.reason}" ,dim=True))
+      return frame
+
+    # ----------------------------------------------------------------------------
+    # actions
+
+    def open_allocate(self ,group):
+      AllocateWindow(self ,group ,self.window_user ,self.acting_user).present()
+
+    def open_subu(self ,subu):
+      ResourceWindow(self.get_application() ,subu ,self.acting_user).present()
+
+  return ResourceWindow
+
+
+def escape(text):
+  return text.replace("&" ,"&amp;").replace("<" ,"&lt;").replace(">" ,"&gt;")
+
+
+def run(window_user ,acting_user):
+  import sys
+  try:
+    Gtk ,Gio ,GLib = load_gtk()
+  except (ImportError ,ValueError) as error:
+    print(GTK_MISSING.format(python=sys.executable ,error=error) ,file=sys.stderr ,end="")
+    return 1
+
+  ResourceWindow = build_window_class(Gtk ,GLib)
+  install_css(Gtk)
+  application = Gtk.Application(application_id=APPLICATION_ID,
+    flags=Gio.ApplicationFlags.NON_UNIQUE)
+  application.connect("activate",
+    lambda app: ResourceWindow(app ,window_user ,acting_user).present())
+  return application.run([])
index 17d5290..64bb1cf 100755 (executable)
@@ -10,15 +10,15 @@ if [ -z "$subu" ]; then
   exit 1
 fi
 
-subu_user="Thomas_$subu"
+subu_user="Thomas·$subu"
 if ! id "$subu_user" &>/dev/null; then
   echo "❌ User $subu_user does not exist"
   exit 1
 fi
 
-# Check required commands
+# Check core system requirements (added setfacl for ACL bridging)
 error_flag=0
-for cmd in machinectl xauth xhost dbus-run-session; do
+for cmd in machinectl dbus-run-session setfacl; do
   if ! command -v "$cmd" &>/dev/null; then
     echo "❌ $cmd not found"
     error_flag=1
@@ -31,30 +31,16 @@ fi
 # don't use sudo -v, because it will echo the password into the emacs shell
 sudo echo >& /dev/null
 
-
-# Something broke when I turned this off. What was it.  Will have to turn it off again and
-# test.
-#
 # Enable lingering so user services can persist
-sudo loginctl enable-linger "$subu_user"
-
-# Decide how to set the use_xauth and use_xhost flags.
-#
-# As of the time of this writing, on my machines, Wayland insists on
-# xauth, while my X11 is refuses to use it, thus it needs xhost control.
-# So this is how I determine how to set the flags here.
-#
-
-# bash will evaluate this variables inside a quoted if even when the
-# gate is falase, so everything needs to be initialized, whether used
-# or not.
+# sudo loginctl enable-linger "$subu_user"
+
 subu_Xauthority_path=""
 use_xauth=0
 use_xhost=0
 if [[ -n "${WAYLAND_DISPLAY:-}" ]]; then
   has_display=true
   XDG_SESSION_TYPE="wayland"
-  subu_Xauthority_path="$HOME/subu/$subu/.Xauthority"
+  subu_Xauthority_path="$HOME/$subu_user/.Xauthority"
   use_xauth=1
   use_xhost=0
   echo "🌀 Wayland session - Using xauth for access control"
@@ -74,26 +60,51 @@ else
   echo "🖳 Console session (no X detected)"
 fi
 
+# Check contextual display requirements
+if [[ "$use_xhost" -eq 1 ]] && ! command -v xhost &>/dev/null; then
+  echo "❌ xhost not found. Required for X11 session access control."
+  exit 1
+fi
+
+if [[ "$use_xauth" -eq 1 ]] && ! command -v xauth &>/dev/null; then
+  echo "❌ xauth not found. Required for Xwayland access control."
+  exit 1
+fi
+
+# Execute Authorization and ACL Bridging
+WAYLAND_DISPLAY_ABS=""
 if [[ "$use_xhost" -eq 1 ]]; then
   xhost +SI:localuser:"$subu_user"
 fi
+
 if [[ "$use_xauth" -eq 1 ]]; then
   mkdir -p "$(dirname "$subu_Xauthority_path")"
   touch "$subu_Xauthority_path"
   xauth extract "$subu_Xauthority_path" "$DISPLAY"
+  # Override umask 0077 to allow the subuser to read the token
+  setfacl -m u:"$subu_user":r "$subu_Xauthority_path"
 fi
 
-if $has_display; then
-
+if [[ -n "${WAYLAND_DISPLAY:-}" ]]; then
+  host_runtime="/run/user/$(id -u)"
+  WAYLAND_DISPLAY_ABS="$host_runtime/$WAYLAND_DISPLAY"
+  # Grant subuser execute (traverse) access to host's runtime dir
+  setfacl -m u:"$subu_user":x "$host_runtime"
+  # Grant subuser rw access to the native Wayland socket
+  setfacl -m u:"$subu_user":rw "$WAYLAND_DISPLAY_ABS"
+fi
 
+if $has_display; then
   sudo machinectl shell "$subu_user"@ /bin/bash -c "
 
     # --- session env from parent ---
-    export DISPLAY=\"${DISPLAY:-${WAYLAND_DISPLAY}}\";
-    export XDG_RUNTIME_DIR='/run/user/$(id -u "$subu_user")';
-    export XDG_SESSION_TYPE=\"$XDG_SESSION_TYPE\";
-    export XDG_SESSION_CLASS=\"user\";
-    export XDG_DATA_DIRS=\"/usr/share/gnome:/usr/local/share/:/usr/share/\";
+    if [ -n \"${DISPLAY:-}\" ]; then export DISPLAY=\"${DISPLAY}\"; fi
+    if [ -n \"${WAYLAND_DISPLAY_ABS:-}\" ]; then export WAYLAND_DISPLAY=\"${WAYLAND_DISPLAY_ABS}\"; fi
+    
+    export XDG_RUNTIME_DIR='/run/user/'\$(id -u \"$subu_user\")
+    export XDG_SESSION_TYPE=\"$XDG_SESSION_TYPE\"
+    export XDG_SESSION_CLASS=\"user\"
+    export XDG_DATA_DIRS=\"/usr/share/gnome:/usr/local/share/:/usr/share/\"
     export USE_XAUTH=$use_xauth
 
     # Only set XAUTHORITY when we actually prepared it (Wayland/xauth case)
@@ -102,34 +113,25 @@ if $has_display; then
     fi
 
     if command -v /usr/bin/gnome-keyring-daemon &>/dev/null; then
-      eval \$(/usr/bin/gnome-keyring-daemon --start)
+      eval \$(/usr/bin/gnome-keyring-daemon --start 2>/dev/null)
       export GNOME_KEYRING_CONTROL GNOME_KEYRING_PID
     fi
 
-    # WirePlumber: ignore logind (subuser isn't the active seat)
-    systemctl --user set-environment WIREPLUMBER_DISABLE_PLUGINS=logind
-    systemctl --user import-environment DISPLAY XAUTHORITY WAYLAND_DISPLAY XDG_RUNTIME_DIR XDG_SESSION_TYPE
-
-    # Bring up audio (sockets first, then services)
-    systemctl --user enable --now pipewire.socket pipewire-pulse.socket >/dev/null 2>&1 || true
-    systemctl --user restart wireplumber pipewire pipewire-pulse
+    # Suppress stderr to prevent benign missing variable warnings on X11
+    systemctl --user import-environment DISPLAY XAUTHORITY WAYLAND_DISPLAY XDG_RUNTIME_DIR XDG_SESSION_TYPE 2>/dev/null || true
 
     exec dbus-run-session -- bash -l
   "
-
 else  
-
   # Console mode with DBus session (give it audio too)
   sudo machinectl shell "$subu_user"@ /bin/bash -c "
-    export XDG_RUNTIME_DIR='/run/user/$(id -u "$subu_user")}';
+    export XDG_RUNTIME_DIR='/run/user/'\$(id -u \"$subu_user\")
 
     systemctl --user set-environment WIREPLUMBER_DISABLE_PLUGINS=logind
-    systemctl --user import-environment XDG_RUNTIME_DIR
+    systemctl --user import-environment XDG_RUNTIME_DIR 2>/dev/null || true
     systemctl --user enable --now pipewire.socket pipewire-pulse.socket >/dev/null 2>&1 || true
     systemctl --user restart wireplumber pipewire pipewire-pulse
 
     exec dbus-run-session -- bash -l
   "
 fi
-
-
diff --git a/developer/authored/replace_from_tar b/developer/authored/replace_from_tar
new file mode 100755 (executable)
index 0000000..6da5c0f
--- /dev/null
@@ -0,0 +1,182 @@
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+replace_from_tar
+
+Makes directories hold exactly what a tar file holds. For each directory at
+the top of the tar file, such as SubU.copy/, the directory of the same name in
+the extract dir is brought to match: files the tar file has are written when
+they are new or differ, and files and directories it does not have are
+deleted. The extract dir is the directory 'tar -x' would be run in: the one
+that holds SubU.copy, not SubU.copy itself. It is the current directory unless
+given. Extracting a tar file over a directory only adds and overwrites, so
+a file that was removed from the tar file would otherwise stay behind.
+
+The tool deletes files. Checkpoint the project with git first, and run
+preview to read what it would do.
+
+Usage: replace_from_tar <command> [argument ...]
+       replace_from_tar preview <command> [argument ...]
+  help | -h | --help             print this message (also printed when given no arguments)
+  replace <tar file> [<extract dir>]
+                                 bring each directory at the top of the tar file,
+                                 in the extract dir, to match the tar file
+  preview <command>              print the log of what <command> would do, change nothing
+
+Example, run in developer/authored, which holds SubU.copy:
+  replace_from_tar preview replace SubU.copy.tar
+  replace_from_tar replace SubU.copy.tar
+"""
+
+import sys ,os ,tarfile
+from pathlib import Path, PurePosixPath
+
+TAG_WIDTH = 12
+
+
+class Log:
+  PREVIEW_TAG = {"made": "would make" ,"replaced": "would replace" ,"deleted": "would delete"}
+
+  def __init__(self ,preview):
+    self.preview = preview
+    self.count = {}
+    self.error_count = 0
+
+  def line(self ,tag ,subject ,reason=None):
+    self.count[tag] = self.count.get(tag ,0) + 1
+    if tag == "unchanged": return
+    shown = self.PREVIEW_TAG.get(tag ,tag) if self.preview else tag
+    print(f"  {shown:<{TAG_WIDTH + 2}}{subject}" + (f"  ({reason})" if reason else ""))
+
+  def error(self ,subject ,reason):
+    self.error_count += 1
+    print(f"  {'error':<{TAG_WIDTH + 2}}{subject}  ({reason})")
+
+  def summary(self):
+    print("summary:")
+    for tag ,what in (("made" ,"files") ,("replaced" ,"files") ,("deleted" ,"files and directories") ,("unchanged" ,"files")):
+      n = self.count.get(tag ,0)
+      verb = f"that would be {tag}" if self.preview and tag != "unchanged" else tag
+      print(f"  {n:>4}  {what} {verb}")
+    if self.error_count:
+      print(f"  {self.error_count:>4}  errors")
+    if self.preview:
+      print("preview only: nothing was changed.")
+    return 1 if self.error_count else 0
+
+
+def member_problem(member):
+  path = PurePosixPath(member.name)
+  if path.is_absolute() or ".." in path.parts:
+    return "an absolute path or '..'"
+  if len(path.parts) < 1:
+    return "an empty name"
+  if not (member.isfile() or member.isdir() or member.issym()):
+    return "neither a file, a directory nor a symbolic link"
+  if member.issym() and (PurePosixPath(member.linkname).is_absolute() or ".." in PurePosixPath(member.linkname).parts):
+    return "a symbolic link leaving its directory"
+  return None
+
+def replace(tar_path ,extract_dir ,log ,preview):
+  extract_dir = Path(extract_dir)
+  if not extract_dir.is_dir():
+    log.error(str(extract_dir) ,"not a directory"); return
+  with tarfile.open(tar_path) as tar:
+    member_list = tar.getmembers()
+    for m in member_list:
+      problem = member_problem(m)
+      if problem:
+        log.error(m.name ,f"refused: {problem}; nothing was changed"); return
+    top_set = sorted({PurePosixPath(m.name).parts[0] for m in member_list})
+    for top in top_set:
+      if any(PurePosixPath(m.name) == PurePosixPath(top) and not m.isdir() for m in member_list):
+        log.error(top ,"refused: the top of the tar file must hold directories; nothing was changed"); return
+      target = extract_dir / top
+      if target.is_symlink():
+        log.error(str(target) ,"refused: it is a symbolic link; nothing was changed"); return
+
+    wanted = {str(PurePosixPath(m.name)): m for m in member_list}
+
+    # ---- delete what the tar file does not have, deepest first
+    for top in top_set:
+      target = extract_dir / top
+      if not target.exists(): continue
+      for dirpath ,dirname_list ,filename_list in os.walk(target ,topdown=False ,followlinks=False):
+        for name in sorted(filename_list) + sorted(d for d in dirname_list if (Path(dirpath) / d).is_symlink()):
+          path = Path(dirpath) / name
+          key = str(PurePosixPath(path.relative_to(extract_dir)))
+          if key not in wanted or wanted[key].isdir():
+            if not preview: path.unlink()
+            log.line("deleted" ,key)
+        for name in sorted(d for d in dirname_list if not (Path(dirpath) / d).is_symlink()):
+          path = Path(dirpath) / name
+          key = str(PurePosixPath(path.relative_to(extract_dir)))
+          if key not in wanted:
+            if not preview:
+              if any(path.iterdir()):
+                log.error(key ,"not empty after its files were deleted"); continue
+              path.rmdir()
+            log.line("deleted" ,key + "/")
+
+    # ---- write what is new or differs
+    for m in sorted(member_list ,key=lambda m: m.name):
+      key = str(PurePosixPath(m.name))
+      path = extract_dir / key
+      if m.isdir():
+        if not path.is_dir():
+          if not preview: path.mkdir(parents=True ,exist_ok=True)
+          log.line("made" ,key + "/")
+        continue
+      if m.issym():
+        if path.is_symlink() and os.readlink(path) == m.linkname:
+          log.line("unchanged" ,key); continue
+        if not preview:
+          if path.is_symlink() or path.exists(): path.unlink()
+          path.parent.mkdir(parents=True ,exist_ok=True)
+          os.symlink(m.linkname ,path)
+        log.line("replaced" if path.is_symlink() else "made" ,key); continue
+      data = tar.extractfile(m).read()
+      mode = m.mode & 0o777
+      if path.is_file() and not path.is_symlink():
+        if path.read_bytes() == data and (path.stat().st_mode & 0o777) == mode:
+          log.line("unchanged" ,key); continue
+        tag = "replaced"
+      else:
+        tag = "made"
+      if not preview:
+        path.parent.mkdir(parents=True ,exist_ok=True)
+        if path.is_symlink(): path.unlink()
+        path.write_bytes(data)
+        os.chmod(path ,mode)
+      log.line(tag ,key)
+
+
+def print_help():
+  print(__doc__.strip())
+  return 0
+
+def CLI(argv=None):
+  if argv is None: argv = sys.argv[1:]
+  if not argv or argv[0] in ("help" ,"-h" ,"--help"): return print_help()
+  preview = argv[0] == "preview"
+  if preview: argv = argv[1:]
+  if not argv:
+    print("Error: 'preview' needs a command after it." ,file=sys.stderr)
+    return 1
+  command ,rest = argv[0] ,argv[1:]
+  if command == "replace" and len(rest) in (1 ,2):
+    if len(rest) == 1: rest = [rest[0] ,"."]
+    if not Path(rest[0]).is_file():
+      print(f"Error: no tar file at {rest[0]}." ,file=sys.stderr)
+      return 1
+    log = Log(preview)
+    print(f"replace_from_tar {'preview ' if preview else ''}replace {rest[0]}   in {os.path.abspath(rest[1])}")
+    replace(rest[0] ,rest[1] ,log ,preview)
+    return log.summary()
+  print(f"Error: unknown command or wrong arguments: '{command}'." ,file=sys.stderr)
+  print_help()
+  return 1
+
+
+if __name__ == "__main__":
+  sys.exit(CLI())
diff --git a/developer/document/SubU_window_Thomas_developer.png b/developer/document/SubU_window_Thomas_developer.png
deleted file mode 100644 (file)
index d25b4e6..0000000
Binary files a/developer/document/SubU_window_Thomas_developer.png and /dev/null differ
diff --git a/document/SubU_window_Thomas_developer.png b/document/SubU_window_Thomas_developer.png
new file mode 100644 (file)
index 0000000..d25b4e6
Binary files /dev/null and b/document/SubU_window_Thomas_developer.png differ
diff --git a/document/design_AccountTree.html b/document/design_AccountTree.html
new file mode 100644 (file)
index 0000000..cade903
--- /dev/null
@@ -0,0 +1,633 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8">
+    <title>AccountTree 5.0 Design</title>
+    <script>
+      {
+        let dirpr_library = null;
+        try{ dirpr_library = localStorage.getItem("RT.dirpr_library"); }catch(e){}
+        window.RT = window.RT || {};
+        RT.dirpr_library = RT.dirpr_library || dirpr_library || "https://style.ReasoningTechnology.com/Manuscript";
+        document.write('<script src="' + RT.dirpr_library + '/Core/RT-Manuscript_make.js"><\/script>');
+      }
+    </script>
+    <script>
+      window.RT.theme_preference('inverse_wheat');
+      window.RT.load('Layout/paginate');
+      window.RT.load('Layout/article_tech_ref');
+      window.RT.load('Element/theme_selector');
+    </script>
+  </head>
+  <body>
+    <RT·theme-selector></RT·theme-selector>
+    <RT·article>
+      <RT·title
+        author="Thomas Walker Lynch"
+        date="2026-10-21"
+        title="AccountTree 5.0 Design">
+      </RT·title>
+
+      <RT·TOC level="1"></RT·TOC>
+
+      <h1>Purpose</h1>
+
+      <p>
+        AccountTree keeps light containers made of Linux accounts. A person's work is divided among accounts, each with its own files and processes, so that a web browser, an email program and a development project cannot read each other's files, without the cost of a virtual machine. This document gives the design as it is currently envisioned, for the standard numbered 5.0. It covers the accounts, the storage that keeps their data, the systems the storage is attached to, the databases, the server that changes accounts and mounts, the programs around the server, and login. Where a decision is still open it is marked as open. The features planned for later standards are listed at the end.
+      </p>
+
+      <p>
+        <RT·code>document/doc_2.html</RT·code> gives the reasoning behind the flat home directories and the idmapped views. It was written in the earlier terms described below; this document builds on it.
+      </p>
+
+      <h1>Ontology</h1>
+
+      <h2>Why the terms changed</h2>
+
+      <p>
+        The project was called SubU, for subordinate user, and spoke of a MasU, a master user, owning its SubUs. In Linux the word user is overloaded. It names the person at the keyboard, human or AI, and it names the account in <RT·code>/etc/passwd</RT·code> that the person uses, as in <RT·code>useradd</RT·code> and user name. The two are different things: one person uses many accounts, and an account has no will of its own. The design came to need the difference everywhere. Two accounts of one person are not the same account, while an account on a system and an account in a storage database can be one account in two places. With one word for both, every sentence about them was ambiguous.
+      </p>
+
+      <p>
+        The MasU and SubU terms had a second fault. Some passages spoke as though there were one MasU at the top, and others as though every SubU could be a MasU. Both were true, because master and subordinate are relative, but the terms gave no word for the top, or for an account with nothing below it.
+      </p>
+
+      <p>
+        So the word user is dropped. A person is the agent; an account is the account. The structure is described in the terms of a tree, which are relative where they should be and fixed where they should be. The project is renamed AccountTree, for what it keeps, and SubU and MasU are retired. The word user survives only in names the project does not own: uid, which is short for user id, <RT·code>useradd</RT·code> and <RT·code>userdel</RT·code>, and the kernel's user namespaces.
+      </p>
+
+      <h2>The account tree</h2>
+
+      <p>
+        Every account on a system has one parent, and root is at the top. In Linux root already administers every account, so making root the top of the tree describes what is already so, and the tree fits into Linux without changing its shape. Root keeps its one meaning.
+      </p>
+
+      <ul>
+        <li><RT·term>person</RT·term>: the agent who logs in and owns data in the world, human or AI.</li>
+        <li><RT·term>account</RT·term>: a Linux account, with its own uid, home, processes and sessions.</li>
+        <li><RT·term>account tree</RT·term>: the accounts of a system, with root at the top.</li>
+        <li><RT·term>parent account</RT·term>: the account directly above an account. <RT·term>child account</RT·term>: an account directly below one.</li>
+        <li><RT·term>login account</RT·term>: an account a person logs in to, with a password or a key, at the login screen, the console or ssh. A login account is a child of root.</li>
+        <li>An account below a login account is a child account in the stronger sense the design uses throughout: it has no password and cannot be logged in to. It is entered from its parent, which has the server start sessions and programs in it, and its parent reaches its files through a view.</li>
+        <li><RT·term>leaf account</RT·term>: an account with no child accounts.</li>
+      </ul>
+
+      <p>
+        An account's name is its parent's name, the middle dot, and its own short name: <RT·code>Thomas·developer·scratch</RT·code> is a child of <RT·code>Thomas·developer</RT·code>, which is a child of the login account <RT·code>Thomas</RT·code>, which is a child of root. In identifiers the wider category comes first, so prose says child account and parent account, and code says <RT·code>account_child</RT·code> and <RT·code>account_parent</RT·code>.
+      </p>
+
+      <h1>Terms</h1>
+
+      <ul>
+        <li><RT·term>storage</RT·term>: a persistent storage device carrying accounts' data, such as the SSD inside a laptop or an encrypted USB drive. Its root is the directory it is mounted at. A storage may be moved between machines.</li>
+        <li><RT·term>tree dir</RT·term>: the directory <RT·code>&lt;storage root&gt;/home/AccountTree</RT·code>, holding the storage database and the accounts' homes. On the disk a system runs from it is <RT·code>/home/AccountTree</RT·code>. AccountTree_admin is given a tree dir to name a storage.</li>
+        <li><RT·term>system</RT·term>: an installed, running Linux, with its own <RT·code>/etc/passwd</RT·code>, its own uid numbering, and its own <RT·code>/home</RT·code>, into which the accounts kept on attached storage are mounted.</li>
+        <li><RT·term>system account</RT·term>: an account in a system's <RT·code>/etc/passwd</RT·code>. <RT·term>storage account</RT·term>: an account in a storage database.</li>
+        <li><RT·term>storage database</RT·term>: the SQLite database in a tree dir, describing the storage accounts kept on that storage.</li>
+        <li><RT·term>system database</RT·term>: the SQLite database at <RT·code>/var/lib/AccountTree/system.sqlite3</RT·code>, a system's own record. Its system map is the authority for which storage accounts are the same accounts as system accounts.</li>
+        <li><RT·term>database uuid</RT·term>: the random identifier a storage database is given when it is made. It names the database, not the storage: a copy of the database carries it too. It is not the storage uuid that a file system is given when it is formatted, which AccountTree does not use.</li>
+        <li><RT·term>system uuid</RT·term>: the random identifier a system database is given when it is made, which names the system.</li>
+        <li><RT·term>database uid</RT·term>: a storage account's uid in its storage database's own numbering. The account's files on the storage carry it.</li>
+        <li><RT·term>system uid</RT·term>: a system account's uid, in <RT·code>/etc/passwd</RT·code>.</li>
+        <li><RT·term>account specifier</RT·term>: the name of an account in one place: <RT·code>system &lt;host name&gt; &lt;system account&gt;</RT·code>, or <RT·code>storage &lt;tree dir&gt; &lt;storage account&gt;</RT·code>.</li>
+        <li><RT·term>same account</RT·term>: two account specifiers that name one account in two places. The system map records which system accounts are the same accounts as which storage accounts.</li>
+        <li><RT·term>view</RT·term>: an idmapped mount through which a parent account sees a child account's files as its own.</li>
+        <li><RT·term>server</RT·term>: the program, running as root, that alone changes accounts, databases and mounts on behalf of persons.</li>
+        <li><RT·term>unmanaged account</RT·term>: an account AccountTree does not manage, such as root, a service's account, or an account made with <RT·code>useradd</RT·code> by hand.</li>
+      </ul>
+
+      <h1>Accounts and names</h1>
+
+      <h2>Account names</h2>
+
+      <p>
+        The middle dot (U+00B7, written <RT·code>·</RT·code>) may not appear in a short name, so a full name divides in exactly one way, and the conventional characters <RT·code>_</RT·code>, <RT·code>-</RT·code> and <RT·code>.</RT·code> remain free for use in names. It lies outside the POSIX portable character set. Making an account needs the <RT·code>--badname</RT·code> flag of <RT·code>useradd</RT·code>, and a tool that checks account names against the portable set needs its check widened. In UTF-8 the middle dot is two bytes, and an account name may currently be at most 32 bytes. That limit is set in user space, by the fixed name field of the <RT·code>utmp</RT·code> login records and by <RT·code>useradd</RT·code>, not by the kernel, which knows accounts only by uid. pkg-config prints paths holding the middle dot in a form that is not valid UTF-8; builds that pass such paths through pkg-config need the workaround given in <RT·code>shared/project-dependency/Python.org</RT·code>.
+      </p>
+
+      <p>
+        The name <RT·code>AccountTree</RT·code> is reserved, because on a running system <RT·code>/home/AccountTree</RT·code> holds AccountTree's own files. A system account made for a branch of a storage database is named with a tilde and eight hex digits, as <RT·code>Thomas·try~a7b3247f</RT·code>, described under Copies. That is a default, not a rule on names: the system map, not the name, records which system accounts belong to a branch.
+      </p>
+
+      <h2>Identity of an account</h2>
+
+      <p>
+        A storage account has an identity in its storage database, and, when the storage is attached to a running system, an extended identity on that system.
+      </p>
+
+      <p>
+        In its storage database, an account is identified by the database uuid together with its database uid. Its files on the storage are owned by that database uid, and root, mounting the storage by hand without AccountTree, sees that database uid on them. This pair is all the storage database needs to identify the account.
+      </p>
+
+      <p>
+        On a running system, the account also needs a system uid, because that numbering is the one <RT·code>/etc/passwd</RT·code> and the kernel use there. A row of the system map gives it: the row pairs the storage account's identity with a system account. The account's extended identity is its identity in its storage database, joined through the system map to the system.
+      </p>
+
+      <h1>Storage and systems</h1>
+
+      <h2>Storage layout</h2>
+
+      <p>
+        Every storage has the same layout under its root, where the root is the directory the storage is mounted at: <RT·code>/</RT·code> for the disk a system runs from, or a mount point such as <RT·code>/mnt/Eagle</RT·code> for a drive plugged in. Its tree dir is <RT·code>home/AccountTree</RT·code> under the root, so <RT·code>/home/AccountTree</RT·code> or <RT·code>/mnt/Eagle/home/AccountTree</RT·code>.
+      </p>
+
+      <RT·code>
+        home/AccountTree/database.sqlite3            the storage database
+        home/AccountTree/account_data/&lt;name&gt;     the home of each storage account, by name
+        home/&lt;other&gt;                               anything else, such as the homes of unmanaged accounts
+      </RT·code>
+
+      <p>
+        Everything AccountTree keeps on a storage is under its tree dir, so the storage can carry other things beside it, and the storage database sits with the data it describes. <RT·code>account_data</RT·code> holds the accounts' homes; on a running system each is reached through an idmapped mount at <RT·code>/home/&lt;system account&gt;</RT·code>. An account named <RT·code>database</RT·code> cannot collide with the database, because the two are at different levels.
+      </p>
+
+      <p>
+        On the disk a system runs from, AccountTree's files are therefore under <RT·code>/home</RT·code>, beside the homes of unmanaged accounts. Distributions already keep <RT·code>/home</RT·code> apart from the operating system: openSUSE on btrfs makes it a subvolume outside the root snapshots, so a rollback of the root leaves it alone, and installers on other file systems commonly offer it as a partition of its own, kept when the system is reinstalled. AccountTree's files inherit this without depending on any file system. Were they on the root file system, a rollback of the root would roll back the storage database and the accounts' files with it.
+      </p>
+
+      <p>
+        A tool that walks <RT·code>/home</RT·code> sees each mounted account's files twice: at <RT·code>/home/&lt;system account&gt;</RT·code>, and at <RT·code>/home/AccountTree/account_data/&lt;name&gt;</RT·code>. A bind mount of the same file system reports the same device number, so options such as <RT·code>rsync -x</RT·code> do not stop at the mount. A backup of <RT·code>/home</RT·code> excludes one of the two.
+      </p>
+
+      <h2>Every storage is the same</h2>
+
+      <p>
+        A storage is a repository of accounts' data, whether it is built into the machine or plugged in, and a machine may have several. The SSD inside a laptop is attached at boot, before any login, in the same way a USB drive is attached when it is plugged in. Taken out of the laptop and put into a USB enclosure, the same SSD can be attached to another machine, and its accounts used there.
+      </p>
+
+      <h2>Copies and branches</h2>
+
+      <p>
+        The database uuid names the database, not the storage it is on. A copy of a tree dir, made for a backup or an archive, carries the same database uuid, and is the same database until one of the two is changed. Because a tree dir can be anywhere, an archive kept at <RT·code>/&lt;storage name&gt;/&lt;date&gt;/home/AccountTree</RT·code> can be attached by giving that tree dir.
+      </p>
+
+      <p>
+        A storage moved between systems, plugged into a desktop, then a laptop, then the desktop again, is the ordinary case. A copy used apart from its original is the case to notice: a storage restored from a backup after the first was lost, or a lost drive found again after work went on on its replacement. The record of transactions tells these apart, as git's history does:
+      </p>
+
+      <ul>
+        <li>Every attach and every detach is a transaction in the storage database. Two copies used apart therefore have different heads, even when no account was made or changed on either.</li>
+        <li>The system records every head of each storage database it sees, in its <RT·code>storage_head_seen</RT·code> table: when one of its accounts is made the same account as a system account, and at each attach and detach.</li>
+      </ul>
+
+      <p>
+        At attach, the storage's record is compared with those heads:
+      </p>
+
+      <ul>
+        <li><em>forward</em>: its record holds the last head seen. It is the storage seen before, moved on, perhaps on another system. It attaches without comment. The system does not care how far along the storage has gone, only that it has not gone back, or sideways.</li>
+        <li><em>backwards</em>: its head is one seen earlier, and another state of the database has been attached since. It is an older copy, or the other line of a branch.</li>
+        <li><em>sideways</em>: its record holds an earlier head seen, but not the last one. It is a branch, two copies each changed after they parted, or an older copy whose head this system never saw.</li>
+        <li><em>unrelated</em>: its record holds no head seen here; it parted before this system first saw it.</li>
+      </ul>
+
+      <p>
+        A storage gone backwards, sideways or unrelated is attached with a warning, and the warning and the kind are recorded. No one is kept from working: a person restoring from a backup attaches the restored storage and goes on, and its line becomes the one this system follows. Merging the two lines afterwards is done by hand in 5.0.
+      </p>
+
+      <p>
+        The two lines can also be kept apart, so that both can be attached at once and their files compared. <RT·code>attach &lt;tree dir&gt; branch</RT·code> makes the storage a branch: it gives the database a new database uuid, records in it the database it came from and the head they parted after, and makes a system account for each storage account that had one on the line it came from, named <RT·code>&lt;system account&gt;~&lt;eight hex digits&gt;</RT·code>. The eight digits begin the hash of the transaction that made the branch, which no other branch has, and which does not change as either line grows. The branch is then a storage database in its own right.
+      </p>
+
+      <p>
+        The same database attached twice would mount two homes at one place. When a storage's homes are already mounted from another copy of it, attach mounts nothing, and says to detach the other copy or attach this one as a branch.
+      </p>
+
+      <p>
+        Bringing two lines together again is a merge. For the storage database, a merge replays the transactions of one line onto the other where they do not conflict. For the accounts' files, it is the integration problem of 7.0, of which merging two lines of one storage is the simplest case.
+      </p>
+
+      <p>
+        These checks establish continuity, not authenticity. The hashes are not secret: they are in the storage database and the system database, and anyone who can read either can copy them into a fabricated database. Authenticity needs a secret, which is the subject of the keys planned for 6.0.
+      </p>
+
+      <h2>Uids are a storage database's own</h2>
+
+      <p>
+        The uids in a storage database are that database's own numbering. Files on the storage carry them. A storage database never adopts the uids of a system it is attached to; its accounts are mounted onto the system through idmapped mounts, which translate database uids to system uids as the files are used. A storage can therefore be moved from machine to machine without changing its files.
+      </p>
+
+      <p>
+        A storage database gives out database uids counting down from 1999999999, while systems give out system uids counting up from low numbers. A database uid is therefore not also the system uid of an account, and a file on a storage, seen without its mount, does not appear to belong to a system account it does not belong to, except in the cases given under Limits of uids. The band 1879048192 to 2147352575 is left unused by systemd's allocation of uids, so a storage database has about 120 million database uids before it leaves the band. An account brought into AccountTree from an existing one can keep its existing uid, with the keyword <RT·code>uid</RT·code> of <RT·code>account make</RT·code>; for that account the coincidence remains, behind the root-only directories of the tree dir.
+      </p>
+
+      <p>
+        A storage database never reuses a database uid. A database uid given up by a deallocated account stays retired, because files carrying it may remain on the storage, in a backup, or on another storage they were copied to.
+      </p>
+
+      <h2>Limits of uids</h2>
+
+      <p>
+        Two numberings meet on a running system: database uids, on the files of storage, and system uids, in <RT·code>/etc/passwd</RT·code>. Each has limits, set by different parties, and each limit is checked where the party that sets it is known.
+      </p>
+
+      <p>
+        <em>The kernel.</em> Linux uids are 32 bits. 4294967295 is (uid_t)−1 and means no uid; 65534 is the overflow uid shown for a uid that cannot be mapped; neither is given out. A database uid is never used as a system uid. At run time it meets only the idmapped mount, which accepts any uid below 4294967295, so the kernel's limit is the same on every system and a storage moved between systems is unaffected by it.
+      </p>
+
+      <p>
+        <em>The file system.</em> ext4, xfs and btrfs store 32-bit uids. JFFS2 and Minix store 16 bits, and FAT and exFAT store none; such storage cannot carry AccountTree accounts. This limit belongs to the storage and travels with it, so it is checked once, when the storage is made: <RT·code>storage make</RT·code> sets the owner of a test file to the first database uid, reads it back, and refuses the storage if the two differ. That check is not yet built.
+      </p>
+
+      <p>
+        <em>The system.</em> <RT·code>UID_MAX</RT·code> in <RT·code>/etc/login.defs</RT·code> bounds only the uids <RT·code>useradd</RT·code> picks on its own; AccountTree gives the system uid. A system's range of system uids is set by <RT·code>system make</RT·code>, 2000 to 59999 by default, and is the system's own. A storage moved to a system with a smaller range keeps its database uids, since they never become that system's uids; the smaller range limits only how many of the storage's accounts the system can make system accounts for, and <RT·code>is-same-account</RT·code> reports when the range is used up.
+      </p>
+
+      <p>
+        <em>Where the two numberings can still meet.</em> A database uid equals the system uid of some account when:
+      </p>
+
+      <ul>
+        <li>an account was brought in with the keyword <RT·code>uid</RT·code> of <RT·code>account make</RT·code>, keeping its existing uid. This is deliberate.</li>
+        <li>a system's own range reaches into the band. <RT·code>system make</RT·code> refuses a range that reaches 1879048192.</li>
+        <li>accounts from outside AccountTree have uids in the band. A directory service mapping Active Directory accounts through SSSD gives out slices of 200000 uids up to 2000200000 by default, which is inside the band, and container uid ranges in <RT·code>/etc/subuid</RT·code> can be set anywhere.</li>
+        <li>a storage database has given out nearly all its band, after about 120 million accounts. <RT·code>account make</RT·code> refuses below 1879048192. Should a database ever need more, a later standard chooses the next band and records it in the database, so that databases already made keep theirs.</li>
+      </ul>
+
+      <p>
+        At attach, the database uids of the storage are to be compared with the system uids in use, from the system's account database, which includes directory services, and from <RT·code>/etc/subuid</RT·code>. Each coincidence is logged with the system account that would appear to own the raw files. A coincidence is not refused: through the mounts each account sees its own files correctly, and the raw files are behind the root-only tree dir. It is reported because a tool that works by uid on the raw files, run as root, would treat them as that account's. This check is not yet built.
+      </p>
+
+      <p>
+        <em>Tools that copy files.</em> Backups and archives copy uids with the files. The old ustar format of tar holds uids up to 2097151, and the odc format of cpio up to 262143; database uids do not fit. GNU tar's default format, the POSIX pax format, cpio's newc format, rsync and btrfs send hold 32-bit uids, and archives of AccountTree storage use one of them.
+      </p>
+
+      <h2>The system</h2>
+
+      <p>
+        A system is a party in its own right, with its own database. Its system database, at <RT·code>/var/lib/AccountTree/system.sqlite3</RT·code>, holds its system uuid, the range of system uids AccountTree gives out, the system map, every head of each storage database it has seen, and the limits. The system owns the uid numbering of <RT·code>/etc/passwd</RT·code>: it alone decides which storage accounts get system accounts on it and with which system uids, so that record is its own and not any storage's. A storage plugged in can claim nothing; what it names in its own database grants it no system account.
+      </p>
+
+      <p>
+        A system never reuses a system uid. Its own disk keeps files carrying system uids, in <RT·code>/tmp</RT·code>, <RT·code>/var/spool</RT·code>, crontabs and logs, and a reused uid would hand them to another account. The system map therefore keeps every row it has held, retired rows included, and a system account AccountTree makes is never given a system uid that appears in it.
+      </p>
+
+      <p>
+        The system database is under <RT·code>/var</RT·code>, which holds the state programs keep, and not under <RT·code>/etc</RT·code>, which holds configuration. A system whose root file system is snapshotted, as openSUSE's is, keeps <RT·code>/var</RT·code> outside the snapshots. A rollback of the root then rolls back <RT·code>/etc/passwd</RT·code> and leaves the system map as it was: a system account made after the snapshot is lost from <RT·code>/etc/passwd</RT·code>, but its system uid stays recorded, is not given out again, and the system account can be made again from the system map. Were the system database under <RT·code>/etc</RT·code>, the rollback would also forget the system uid, and it could be given to another account while files carrying it remained under <RT·code>/var</RT·code>.
+      </p>
+
+      <h2>Deleting storage</h2>
+
+      <p>
+        <RT·code>AccountTree_admin storage delete &lt;tree dir&gt;</RT·code> undoes, on this system, what AccountTree did for the accounts of the storage database, then deletes the tree dir. It makes every check first, and a failed check changes nothing:
+      </p>
+
+      <ul>
+        <li>the storage database names no other system that has seen it, since that system's map would name a database that no longer exists, unless the keyword <RT·code>forget-other-systems</RT·code> is given;</li>
+        <li>no system account that is the same account as one of its accounts has a login session;</li>
+        <li>nothing is mounted inside the tree dir;</li>
+        <li>the tree dir holds only the storage database and <RT·code>account_data</RT·code>, and <RT·code>account_data</RT·code> holds only homes of the database's accounts;</li>
+        <li>homes that hold files are deleted only when the keyword <RT·code>delete-account-data</RT·code> is given.</li>
+      </ul>
+
+      <p>
+        For each row of this system's map naming one of its accounts, it then unmounts the home, deletes the mount point attach made, recognized as an empty directory owned by root with mode 0000, deletes the system account and its group when AccountTree made them, keeps an adopted system account, and retires the row. Last it deletes <RT·code>account_data</RT·code>, the storage database and the tree dir. Run a second time, it finds nothing to delete and says so. <RT·code>system delete</RT·code> deletes the system database, and is refused while any row of the system map is in use.
+      </p>
+
+      <h1>The databases</h1>
+
+      <p>
+        There are two kinds of database, with two schemas, <RT·code>schema_storage.sql</RT·code> and <RT·code>schema_system.sql</RT·code>. Both keep the same record of changes, described below, and are checked the same way. Each fact is kept by the party with authority over it: a storage database keeps its accounts and their database uids; the system database keeps which of those accounts are the same accounts as system accounts.
+      </p>
+
+      <h2>Storage database tables</h2>
+
+      <ul>
+        <li><RT·code>identity</RT·code>: the database uuid, the uuid it was made with, from which its chain begins, and for a branch, the database it came from and the head they parted after; the series number, the head hash, and when it was made. One row. A storage database has no name for people. A name would travel with every copy of the database, and a name taken from the storage it was made on would come to mislead once the database was copied elsewhere. A database is shown by its database uuid, its series number, its head hash and the time of its last change, and log lines give the first eight digits of the database uuid.</li>
+        <li><RT·code>standard</RT·code>: the AccountTree standards this database follows, one row each. Currently <RT·code>5.0</RT·code>.</li>
+        <li><RT·code>account</RT·code>: one row per storage account: database uid, name, the database uid and name of its parent, state (<RT·code>active</RT·code>, <RT·code>deprecated</RT·code>, <RT·code>deallocated</RT·code>), and times. The parent's database uid is empty when the parent is root or is carried by another database.</li>
+        <li><RT·code>database_uid</RT·code>: every database uid this database has ever given out, with whether it is in use or retired.</li>
+        <li><RT·code>system_seen</RT·code>: the systems that have seen this database, with when that was first and last. For the owner of the storage, and for <RT·code>storage delete</RT·code>, which will not leave a system mapping a database that no longer exists. Never authority.</li>
+        <li><RT·code>acquaintance_database</RT·code> and <RT·code>same_account_database</RT·code>: other storage databases this one knows, and which of their accounts are the same accounts as accounts here, by database uid. Written into both databases. Not used in 5.0.</li>
+        <li><RT·code>transaction_log</RT·code>: the record of every change, described below.</li>
+      </ul>
+
+      <h2>System database tables</h2>
+
+      <ul>
+        <li><RT·code>identity</RT·code>: the system uuid, the series number, the head hash, and when the database was made. One row. The system is shown by its host name, read when it is shown, so a name for people is not kept.</li>
+        <li><RT·code>standard</RT·code>: as for a storage database.</li>
+        <li><RT·code>system_uid_range</RT·code>: the first and last system uid AccountTree gives a system account it makes. One row.</li>
+        <li><RT·code>system_map</RT·code>: one row for each time a system account has been made the same account as a storage account: the system uid and gid, the system account's name, the database uuid and database uid, whether AccountTree made the system account or adopted one that existed, whether the row is in use or retired, and when. At most one row in use names a given system uid, and at most one names a given storage account. A row is retired, never deleted. An adopted system account may be made the same account again once its row is retired; a system uid that appears in any row is never given to a system account AccountTree makes.</li>
+        <li><RT·code>storage_head_seen</RT·code>: every head of each storage database this system has seen, with the event (mapped, attached, detached), and at attach, the continuity found.</li>
+        <li><RT·code>account_limit</RT·code>: the number of child accounts each system account may have below it, by system uid. A system account with no row may have none.</li>
+        <li><RT·code>transaction_log</RT·code>: the record of every change.</li>
+      </ul>
+
+      <h2>Series number and hash chain</h2>
+
+      <p>
+        A transaction is one change to one database: the SQL statements a command of <RT·code>AccountTree_admin</RT·code>, or a request to the server, runs against that database, recorded together. A command can make transactions in both kinds of database; <RT·code>is-same-account</RT·code> makes one in the storage database and one in the system database.
+      </p>
+
+      <p>
+        Each transaction increments the series number, and records its statements, a description, a random number, and a hash. The hash is taken over the previous transaction's hash, the series number, the description, the statements, and the random number. The first transaction follows a hash made from the database's kind and the uuid it was made with, so no two databases' chains can coincide, and a record cannot be moved from one database into another. A branch keeps its record, and the transaction that made it a branch is in that record. The <RT·code>identity</RT·code> row holds the series number and hash of the last transaction, the head of the chain.
+      </p>
+
+      <p>
+        The chain gives order, integrity and identity of history. It does not give authenticity. Anyone holding a database can extend its chain, or rewrite it and compute new hashes. What a rewrite cannot do is keep the hashes that others have already seen: a system that remembers a head finds that the rewritten record does not hold it. Each party that remembers a head is a witness. A storage database and the system database are readable by root only, so the record is the database's own history, as a git repository's history is, rather than a log for persons to read.
+      </p>
+
+      <p>
+        The series number orders a database's history; the hash identifies it. A copy that is used goes on to make its own transactions, with the same series numbers as the original's and different contents. Their hashes differ, because the random number differs even when the same change is made to both. So two databases with the same database uuid and series number but different heads are known to have parted.
+      </p>
+
+      <p>
+        Times recorded in a database are for people to read. They are not authoritative, because clocks differ between machines. The series number is authoritative.
+      </p>
+
+      <h2>Standards</h2>
+
+      <p>
+        A program that opens a database first reads its <RT·code>standard</RT·code> table, and refuses a database that follows no standard the program follows. A database may list more than one standard, during a change from one to the next.
+      </p>
+
+      <h2>Fallback tools</h2>
+
+      <p>
+        The databases can be built by hand from SQL scripts kept in the project. They are the means of starting over if a database is lost or damaged.
+      </p>
+
+      <ul>
+        <li>A schema script builds an empty database where there was none.</li>
+        <li>Initialization scripts put in the starting data: the identity row, the standard, existing accounts, the uid range, and limits.</li>
+        <li>Scripts with their data written into them can write the system map rows for existing system accounts, for a system whose system database is lost.</li>
+      </ul>
+
+      <h1>Same accounts</h1>
+
+      <p>
+        A storage account's home, <RT·code>account_data/&lt;name&gt;</RT·code> in its tree dir, carries only its database uid. What lets it be used on a system is a row of that system's map, saying that a system account there is the same account as the storage account. The administrator states it:
+      </p>
+
+      <RT·code>
+        AccountTree_admin is-same-account system &lt;host name&gt; &lt;system account&gt; storage &lt;tree dir&gt; &lt;storage account&gt; [make-system-account]
+      </RT·code>
+
+      <p>
+        A keyword such as <RT·code>make-system-account</RT·code> is a word given after a command's other arguments, as the command itself is a word, not a flag. The two account specifiers may be given in either order. In 5.0 one names a system account and the other a storage account; correspondences between two storage databases come later. The host name must be this system's, which confirms that the administrator is on the machine he means. When the system account exists, it is adopted: it must be an account of a person, with a system uid at or above <RT·code>UID_MIN</RT·code>, its home must be <RT·code>/home/&lt;system account&gt;</RT·code>, and no other storage account may already be the same account as it. When it does not exist, the keyword <RT·code>make-system-account</RT·code> makes it, with a system uid from the system's range that no account, no group and no row of the system map has had, provided <RT·code>/home/&lt;system account&gt;</RT·code> is absent or empty, since the new system account would take whatever is there as its home. Without the keyword the command refuses, so that a mistyped name does not make a system account.
+      </p>
+
+      <p>
+        The first time a storage account is made the same account as a system account on a system, the storage database records that system in <RT·code>system_seen</RT·code>. That and the system map row together are what 5.0 calls an introduction; in 6.0 it is also when keys are exchanged. <RT·code>account make</RT·code> with <RT·code>make-system-account</RT·code> makes a storage account and, in the same command, a system account of the same name, the same account as it.
+      </p>
+
+      <p>
+        <RT·code>is-not-same-account</RT·code> undoes it: it unmounts the home if it is mounted from that storage, deletes the mount point attach made, deletes the system account if AccountTree made it and keeps an adopted one, and retires the row. <RT·code>same-account show</RT·code> lists which accounts are the same, with the storage accounts and the persons' system accounts that are not. <RT·code>same-account suggest</RT·code> prints <RT·code>is-same-account</RT·code> commands proposed by matching names, for the administrator to read and run; it changes nothing. Matching names is a guess, and is used nowhere else.
+      </p>
+
+      <p>
+        Someone else's storage, plugged into this system, gets nothing until the administrator makes its accounts the same accounts as system accounts here. There is no guest attach in 5.0: every storage is introduced by the administrator. A storage account named <RT·code>Thomas</RT·code> on that storage is no one here until the system map says so.
+      </p>
+
+      <p>
+        Two storage databases may say that an account of one is the same account as an account of the other, and they may say it while a system's map says otherwise. Such a claim grants nothing until a system acts on it, as in mounting a parent account's view of a child account kept on other storage. A system acts only on its own record: it does not infer that two accounts are the same from what storage databases say, and where the claims of storage databases and its own map disagree, it reports the disagreement. A child account made through the server on this system was made by this system, which records the parent relation itself.
+      </p>
+
+      <p>
+        In 5.0 storage databases and systems recognize each other by database uuid and system uuid alone. A copy of a storage database is taken for the original, and gains the system accounts mapped to the original's accounts. That is enough among one person's machines and storage. It is not enough among strangers, because either kind of uuid can be copied; keys are given under standard 6.0.
+      </p>
+
+      <h1>The server</h1>
+
+      <h2>What it is</h2>
+
+      <p>
+        The server is the one program that changes accounts, databases and mounts on behalf of persons. It runs as root, as a system service, and takes requests from any account over a Unix domain socket, <RT·code>/run/AccountTree/socket</RT·code>. The socket is open to every account; the server decides what each request may do.
+      </p>
+
+      <p>
+        The server handles one request at a time. Two requests can then never give out the same uid or the same name, and no locking is needed.
+      </p>
+
+      <h2>Who is asking</h2>
+
+      <p>
+        The server learns the requesting account from the kernel. After a client connects, the server reads the <RT·code>SO_PEERCRED</RT·code> socket option, which gives the process id, uid and gid the kernel recorded for the client when it called <RT·code>connect</RT·code>. The client does not send them, so it cannot misstate them. The server authorizes by the uid and never by the process id, because process ids are reused. The uid gives the system account's name, and that name is the prefix of any child account the account makes.
+      </p>
+
+      <h2>Requests</h2>
+
+      <p>
+        Each request is checked, carried out as a database transaction and the system changes that go with it, and answered. An account may act on itself and on any account in the tree below it, with the authority of the requesting account; this is how a child account's window, opened from its parent's, acts with the parent's authority.
+      </p>
+
+      <ul>
+        <li><RT·code>make</RT·code> &lt;short name&gt;: make a child account of the requesting account. Checks: the short name is valid and not in use; the requesting account's limit, and the limit of every account above it in its tree, allow one more. The child account is made in the storage database of the requesting account unless another attached storage is named. Steps: a database uid, never given before, and the account row in the storage database; a system uid, never given before, and the row in the system map; the system account; the home in <RT·code>account_data</RT·code>, owned by the database uid, mode 0700; the mount of it at <RT·code>/home/&lt;name&gt;</RT·code>; the parent's view.</li>
+        <li><RT·code>rename</RT·code> &lt;account&gt; &lt;new short name&gt;: rename a child account. The account, and every account below it, whose names begin with its name, must have no session; all are renamed together. The uids and files do not change; the homes, mount points and views are renamed with them.</li>
+        <li><RT·code>deprecate</RT·code> &lt;account&gt;: end the account's sessions, lock it, and mark it deprecated. Its data, name and uid are kept. It still counts against the limits. A deprecated account can be restored.</li>
+        <li><RT·code>deallocate</RT·code> &lt;account&gt;: remove a deprecated account. Its system account and views are removed, and its database uid and system uid are retired. What becomes of its data is open: the proposal is to move it to a holding directory on the storage, from which only the administrator removes it.</li>
+        <li><RT·code>view</RT·code> [&lt;account&gt;]: report an account and its tree: names, states, storage, and whether each is mounted. Changes nothing.</li>
+        <li><RT·code>set views</RT·code>: mount the requesting account's views of its child accounts that are on attached storage. Views already mounted are left as they are.</li>
+        <li><RT·code>reconcile</RT·code>: compare the databases with <RT·code>/etc/passwd</RT·code> and the mount table, complete what an interrupted request left undone, make again any system account lost from <RT·code>/etc/passwd</RT·code>, and report what differs. Run at start and on demand. Administrator only.</li>
+      </ul>
+
+      <p>
+        The administrator's commands are not requests. <RT·code>AccountTree_admin</RT·code> runs as root and calls the work functions itself, so making accounts the same, attaching, detaching and setting limits work while the server is stopped, at boot for instance. The server and <RT·code>AccountTree_admin</RT·code> may both write a database; SQLite's lock lets one transaction in at a time.
+      </p>
+
+      <h2>Partial failure</h2>
+
+      <p>
+        A request makes several changes, and any of them can fail after the earlier ones succeeded. The databases record the intended state of each account. A failure leaves the intended state recorded and the system short of it; <RT·code>reconcile</RT·code> finishes the work or reports why it cannot. A request is complete only when the system matches the databases.
+      </p>
+
+      <h2>Protocol</h2>
+
+      <p>
+        A request is one JSON object on one line, carrying the protocol version, the request name, and its arguments. The answer is one JSON object on one line, carrying whether the request succeeded, its result, and on failure a message for a person to read.
+      </p>
+
+      <h2>The service unit</h2>
+
+      <p>
+        The server's systemd unit must not set <RT·code>PrivateMounts</RT·code>, <RT·code>ProtectHome</RT·code> or any other option that gives the service a mount namespace of its own. A mount made inside such a namespace exists only for the service, and no account ever sees it.
+      </p>
+
+      <h1>Limits</h1>
+
+      <p>
+        Making a child account takes a database uid from a storage database, and a system uid and a system account from the system, and the system's are finite. Without a limit, one account could take all of them. Each system account has a limit on the number of accounts in its tree below it, counting accounts at every depth and counting deprecated ones. Limits are kept in the system database. The administrator sets them with <RT·code>AccountTree_admin limit set</RT·code>. A system account with no limit set has a limit of 0, and cannot make child accounts.
+      </p>
+
+      <p>
+        A child account made by a child account counts against the limits of every account above it. The starting value suggested for a login account is 100.
+      </p>
+
+      <h1>Mounts</h1>
+
+      <h2>Idmapped mounts</h2>
+
+      <p>
+        Every AccountTree mount is an idmapped mount: a bind mount that carries a translation of uids, applied by the kernel to every permission check and to every file made through it. Idmapped mounts need Linux 5.12 or later, and a file system that supports them, such as ext4, btrfs or xfs. An idmapped mount is kernel state, with no process behind it, so it survives the restart of the server and the end of any session. A mount through FUSE, such as bindfs, does neither.
+      </p>
+
+      <p>
+        AccountTree makes the mount with the kernel's own calls, not by running <RT·code>mount</RT·code>. <RT·code>open_tree</RT·code> clones the source as a mount not yet attached anywhere; <RT·code>mount_setattr</RT·code> gives it <RT·code>nosuid</RT·code>, <RT·code>nodev</RT·code> and the idmap; <RT·code>move_mount</RT·code> places it. The kernel is given the idmap as a user namespace: a child process is made in a new user namespace, its uid and gid maps are written, and the namespace is handed to <RT·code>mount_setattr</RT·code>. A map line reads <RT·code>&lt;id inside&gt; &lt;id outside&gt; &lt;count&gt;</RT·code>; for an idmapped mount the kernel takes the uid on disk, the database uid, as the id inside, and the id outside, the system uid, as the uid seen through the mount.
+      </p>
+
+      <h2>Attaching storage</h2>
+
+      <p>
+        <RT·code>AccountTree_admin attach &lt;tree dir&gt;</RT·code> verifies the storage database's standards and hash chain, compares its record with the heads seen here, as described under Copies and branches, then reads this system's map for the rows naming that database uuid, and nothing else. For each, it checks that the storage account is active and that the system account exists with its system uid and with its home at <RT·code>/home/&lt;system account&gt;</RT·code>, and mounts the storage account's home there, translating the database uid to the system uid. Storage accounts with no row are logged as skipped. The mounts are <RT·code>nosuid</RT·code> and <RT·code>nodev</RT·code>, since a storage can carry set-user-id programs and device files. Files on the storage owned by database uids the mount does not translate appear owned by <RT·code>nobody</RT·code>.
+      </p>
+
+      <h2>Views</h2>
+
+      <p>
+        A parent account sees each of its child accounts at <RT·code>~/account_child/&lt;short name&gt;</RT·code>: an idmapped mount of the child account's home that translates the child's uid to the parent's. Files there appear to be the parent's, and files the parent makes there belong to the child. The directory is named for the property each of its entries has, being a child account, so the name is singular. Views are set when storage is attached, for the parent accounts already in session, and at each session's start, through <RT·code>set views</RT·code>. Views are designed in outline only, and come next after this document's other parts are built.
+      </p>
+
+      <h2>Mount points</h2>
+
+      <p>
+        A mount point must exist before a mount, so <RT·code>/home/&lt;system account&gt;</RT·code> exists for every known account, attached or not. While nothing is mounted on it, it is an empty directory owned by root with mode 0000, and marked immutable. No account can enter it, and nothing can be written into it to be hidden later by the mount.
+      </p>
+
+      <h1>Login and entering</h1>
+
+      <p>
+        A login account whose storage is not attached has no home. A login into the empty mount point finds no initialization files, and does not read them later when the storage arrives. Login is therefore refused for a managed login account whose home is not mounted.
+      </p>
+
+      <p>
+        The check is in PAM, which every way of logging in passes through: the display manager, the console, ssh, <RT·code>su</RT·code> and <RT·code>machinectl shell</RT·code>.
+      </p>
+
+      <RT·code>
+        account sufficient pam_succeed_if.so uid = 0 quiet
+        account required   pam_exec.so quiet /usr/local/lib/AccountTree/home_present
+      </RT·code>
+
+      <p>
+        <RT·code>home_present</RT·code> allows the login when the account is not managed by AccountTree, or when <RT·code>/home/&lt;name&gt;</RT·code> is a mount point, and refuses it otherwise. The first line keeps root able to log in whatever the check does.
+      </p>
+
+      <p>
+        A child account is not logged in to. It is entered from its parent: the parent asks the server to start a session or a program in it, and the server does so only for an account below the requester in the tree. Whether a child account may ever be logged in to directly is open.
+      </p>
+
+      <p>
+        A session, when it starts, sends <RT·code>set views</RT·code>. The server knows the account from the socket, so a session can set only its own views.
+      </p>
+
+      <h1>Programs and modules</h1>
+
+      <ul>
+        <li><RT·code>AccountTree</RT·code>: the person's program. Its commands send requests to the server; its <RT·code>window</RT·code> command opens the resource window, which shows the account's child accounts and their states and sends the same requests. Runs as the person's account.</li>
+        <li><RT·code>AccountTree_admin</RT·code>: the administrator's program. Root. Its commands: <RT·code>system make</RT·code>, <RT·code>system show</RT·code> and <RT·code>system delete</RT·code>; <RT·code>storage make</RT·code>, <RT·code>storage show</RT·code> and <RT·code>storage delete</RT·code>, each given a tree dir; <RT·code>account make</RT·code>; <RT·code>is-same-account</RT·code>, <RT·code>is-not-same-account</RT·code>, <RT·code>same-account show</RT·code> and <RT·code>same-account suggest</RT·code>; <RT·code>attach</RT·code> and <RT·code>detach</RT·code>; and <RT·code>limit set</RT·code>. At boot, before logins are allowed, <RT·code>attach</RT·code> is run for every storage present, the disk inside the machine included, so that <RT·code>/home</RT·code> is populated. Detaching is refused while a system account it would unmount has a session. Unlocking an encrypted storage and mounting it come later.</li>
+        <li><RT·code>AccountTree_server</RT·code>: the server, started by its systemd unit. Nobody types it.</li>
+        <li><RT·code>home_present</RT·code>: the PAM check.</li>
+        <li>The schema and initialization scripts.</li>
+      </ul>
+
+      <p>
+        Each program keeps the RT convention of a command line interface apart from its work functions, and the modules are imported as libraries: a window, a server or a test calls the functions directly, never the command line. There are two layers of work functions. Those that change the system run as root; <RT·code>AccountTree_admin</RT·code> and the server import them. The person's functions send requests to the server and return its replies; <RT·code>AccountTree</RT·code> and the window import them. The socket between the layers is where privilege changes.
+      </p>
+
+      <p>
+        The modules are named and shaped as they are meant to stay. Where a direct call is not yet written, a wrapper function stands in its place, named for what it does here and commented with the direct call it will become:
+      </p>
+
+      <ul>
+        <li><RT·code>kernel</RT·code>: direct calls, made through ctypes. The idmapped mount through <RT·code>open_tree</RT·code>, <RT·code>mount_setattr</RT·code> and <RT·code>move_mount</RT·code>; unmounting through <RT·code>umount2</RT·code>; the mount table from <RT·code>/proc/self/mountinfo</RT·code>; the immutable flag through the <RT·code>FS_IOC_SETFLAGS</RT·code> ioctl; login sessions through <RT·code>sd_get_uids</RT·code> in libsystemd.</li>
+        <li><RT·code>system_account</RT·code>: reading system accounts directly, through the C library, and changing them through wrappers, <RT·code>system_account_make</RT·code>, <RT·code>system_account_delete</RT·code> and <RT·code>system_group_delete</RT·code>, which run the shadow suite's commands for now. In the future each will be a direct call: the lines of <RT·code>/etc/passwd</RT·code>, <RT·code>/etc/shadow</RT·code>, <RT·code>/etc/group</RT·code> and <RT·code>/etc/gshadow</RT·code> edited under the C library's lock, <RT·code>lckpwdf</RT·code>, each new file written beside the old one and renamed into place.</li>
+        <li><RT·code>database</RT·code>, <RT·code>system</RT·code>, <RT·code>storage</RT·code> and <RT·code>same_account</RT·code>: the databases and the work functions on them.</li>
+        <li><RT·code>account_tree</RT·code>, <RT·code>authority</RT·code>, <RT·code>resource_kind</RT·code> and <RT·code>window</RT·code>: the person's side.</li>
+      </ul>
+
+      <p>
+        Keeping the root functions separate, and their calls direct, lets them later be moved into a smaller program of their own, or into the kernel, where an account tree with root at its top fits Linux as it is.
+      </p>
+
+      <h1>Open questions</h1>
+
+      <ul>
+        <li>What deallocation does with an account's data.</li>
+        <li>Whether a child account may ever be logged in to directly.</li>
+        <li>Whether a parent account's views are removed when its last session ends, or left until the storage is detached.</li>
+        <li>How resources other than accounts and storage, such as network zones and devices, are recorded in the database.</li>
+      </ul>
+
+      <h1>Road map</h1>
+
+      <p>
+        Standard 5.0 is the design above. Later standards add the following, each described in its own section. A database lists the standards it follows in its <RT·code>standard</RT·code> table, so a database moves to a later standard by listing it, and a program that follows only an earlier one refuses it.
+      </p>
+
+      <ul>
+        <li>6.0: longer account names; keys, for unlocking storage and recognizing it; signed heads; deduplication; deep freeze.</li>
+        <li>7.0: integration of archives and of child copies, and merging of lines; a property file system.</li>
+        <li>Later, version not yet assigned: guests.</li>
+      </ul>
+
+      <p>
+        <em>Guests.</em> A person who plugs storage into someone else's machine and works there, with no administrator introducing it first, makes the machine a display with a processor for whatever storage is brought to it. The guest's storage accounts would be given system accounts at attach, in a band of system uids of their own, recorded in the system map as transient, and deleted at detach, with no rights to anything of the machine's owners. In 5.0 every storage is introduced by the administrator. Guests wait until keys, automatic attach, and the protection of a machine from what a stranger's storage carries, are understood.
+      </p>
+
+      <h1>Standard 6.0</h1>
+
+      <h2>Longer account names</h2>
+
+      <p>
+        Account names built from a chain of parent names grow long, and 32 bytes, with two for each middle dot, is soon reached. 6.0 is to allow names of 128 bytes. The limit is in user space: the login records and <RT·code>useradd</RT·code>. Distributions are replacing the <RT·code>utmp</RT·code> records with <RT·code>wtmpdb</RT·code> and <RT·code>lastlog2</RT·code>, which have no fixed name field; openSUSE has done so. What remains is the check in <RT·code>useradd</RT·code>, which goes away when AccountTree edits the account files itself. Whether a system already accepts longer names is found by making a test account with a name past 32 bytes.
+      </p>
+
+      <h2>Keys</h2>
+
+      <p>
+        In 5.0, databases and systems recognize each other by database uuid and system uuid. 6.0 adds two uses of keys.
+      </p>
+
+      <p>
+        <em>Unlocking storage.</em> A storage's file system is encrypted with LUKS2, which keeps up to 32 key slots, any of which opens it. Each person whose accounts are on the storage has a slot, opened by a passphrase or by a hardware token such as a FIDO2 key. When a storage is introduced to a system, the administrator opens it once, and the system adds a slot of its own, with a key it keeps, so that the storage is opened and attached on that system without asking anyone for a password. systemd-cryptenroll already enrolls such slots, and can seal a key in the machine's TPM, the security chip on its motherboard, so that the key is useless away from that machine. A slot can be removed from any machine that can open the storage, which is how a stolen laptop is cut off from it. Opening a storage with a key the system enrolled is also the proof that it is the storage that was introduced, which uuids and hashes cannot give: a fabricated storage cannot be opened with that key. The keys a system keeps make the place they are kept a target in the way <RT·code>/etc/shadow</RT·code> is; sealing them in the TPM is the protection. The disk inside the machine can be treated the same way, its <RT·code>/home</RT·code> opened by the system's own slot at boot, which keeps every storage alike.
+      </p>
+
+      <p>
+        <em>Signed heads.</em> Each database and each system also holds a key pair. Each transaction's hash, which covers every transaction before it, is signed with the private key of the party that wrote it, so that a head can be shown to have been written by whoever holds that key, and not only to follow from the heads before it. The signature is checked with the public key, and the first <RT·code>is-same-account</RT·code> between a system and a storage database records each one's public key in the other's database, alongside its database uuid or system uuid. On attach, each side checks that the other holds the private key matching the public key recorded for it, so a copied database uuid or system uuid alone is refused. A storage is not a running party, so a database's private key is a file on its storage, and a full copy of the database copies the key too; protecting the key, for example inside the storage's encryption, is part of the 6.0 design.
+      </p>
+
+      <p>
+        The migration touches only the identity and acquaintance tables and the programs that read them. A 6.0 database adds the key columns and lists both <RT·code>5.0</RT·code> and <RT·code>6.0</RT·code> in its standard table while keys are exchanged. The system map, uids and the account table are unchanged, because an account is still identified by its database and its database uid.
+      </p>
+
+      <h2>Deduplication</h2>
+
+      <p>
+        A storage gathers copies of the same files, from old archives and from copies made between accounts. On btrfs, two files with the same content can be made to share their extents, after which the content is stored once while the files stay separate: a later write to one of them is written apart from the other. The kernel call that does this, <RT·code>FIDEDUPERANGE</RT·code>, compares the two ranges byte for byte before sharing them, so a false match on a hash cannot corrupt data. Tools such as duperemove and bees find candidates by hashing and leave the comparison to that call.
+      </p>
+
+      <p>
+        The AccountTree server already runs as a service, and is a natural home for a background pass over attached storage. The pass keeps an index of content hashes, gives the kernel call the pairs whose hashes match, and so keeps each storage compacted as it is used. On a file system without shared extents, the pass reports duplicates and changes nothing. The same index serves integration in 7.0.
+      </p>
+
+      <h2>Deep freeze</h2>
+
+      <p>
+        A person's accounts, archives included, may not fit on one portable storage: 500 GB, say, against more data than that. Most of it is seldom read. Deep freeze divides an account's home into a working part and a frozen part by access time. Files not read for a long time move to the frozen part, which is kept on other storage, and a copy to a storage that cannot hold all of it takes the working part only. Whether the frozen part's directory tree stays visible, with its content brought in when its storage is attached, is open.
+      </p>
+
+      <p>
+        Access times are recorded only when a file system is mounted with <RT·code>atime</RT·code> or <RT·code>relatime</RT·code>. <RT·code>relatime</RT·code>, the Linux default, updates a file's access time at most once a day, which is enough here; under <RT·code>noatime</RT·code> nothing is recorded, and deep freeze cannot work. Bringing a working part back after it was changed elsewhere is the integration problem of 7.0.
+      </p>
+
+      <h1>Standard 7.0</h1>
+
+      <h2>Integration</h2>
+
+      <p>
+        Two kinds of integration are needed. The first brings the files of old archives into the current accounts. The old archives have older, less mature directory structures, and the aim is to place their files in the current structure, not to keep the old structures. The content-hash index kept for deduplication shows which old files already exist in the current tree, under any name, and leaves the rest to be placed.
+      </p>
+
+      <p>
+        The second brings back a child copy: part of an account's home copied to a portable storage, changed there, and returned. Its changes are merged into the original. A change to the directory structure on either side is the hard case, as when a file is moved on one side and changed on the other. Two lines of one storage, described under Copies and branches, are merged the same way.
+      </p>
+
+      <h2>Property file system</h2>
+
+      <p>
+        Both kinds of integration are hard because a file has one place in one tree. A file system organized by properties, or tags, would let a file belong to an old structure and a new one at once, and make a directory a query. AccountTree's contract with storage is that an account's home is a directory mounted at <RT·code>/home/&lt;system account&gt;</RT·code>, and the mounting is one function in the code. A home provided by a property file system changes that function and nothing else, provided the file system supports idmapped mounts or translates uids itself. At that point the AccountTree project is expected to fork.
+      </p>
+
+    </RT·article>
+  </body>
+</html>
diff --git a/document/design_SubU.html b/document/design_SubU.html
new file mode 100644 (file)
index 0000000..4ca1784
--- /dev/null
@@ -0,0 +1,560 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8">
+    <title>SubU 5.0 Design</title>
+    <script>
+      {
+        let dirpr_library = null;
+        try{ dirpr_library = localStorage.getItem("RT.dirpr_library"); }catch(e){}
+        window.RT = window.RT || {};
+        RT.dirpr_library = RT.dirpr_library || dirpr_library || "https://style.ReasoningTechnology.com/Manuscript";
+        document.write('<script src="' + RT.dirpr_library + '/Core/RT-Manuscript_make.js"><\/script>');
+      }
+    </script>
+    <script>
+      window.RT.theme_preference('inverse_wheat');
+      window.RT.load('Layout/paginate');
+      window.RT.load('Layout/article_tech_ref');
+      window.RT.load('Element/theme_selector');
+    </script>
+  </head>
+  <body>
+    <RT·theme-selector></RT·theme-selector>
+    <RT·article>
+      <RT·title
+        author="Thomas Walker Lynch"
+        date="2026-10-16"
+        title="SubU 5.0 Design">
+      </RT·title>
+
+      <RT·TOC level="1"></RT·TOC>
+
+      <h1>Purpose</h1>
+
+      <p>
+        This document gives the design of SubU as it is currently envisioned, for the standard numbered 5.0. It covers the users, the storage that carries them, the systems they are attached to, the databases, the server that changes accounts and mounts, the programs around the server, and login. Where a decision is still open it is marked as open. The features planned for standard 6.0 are listed at the end, with the path from 5.0 to them.
+      </p>
+
+      <p>
+        <RT·code>document/doc_2.html</RT·code> gives the reasoning behind the flat home directories and the mapped views. This document builds on it.
+      </p>
+
+      <h1>Terms</h1>
+
+      <ul>
+        <li><RT·term>user</RT·term>: a Unix account, with its own uid, home directory, processes and sessions.</li>
+        <li><RT·term>MasU</RT·term>: master user. A user that owns other users. Plural MasUs.</li>
+        <li><RT·term>SubU</RT·term>: subordinate user. A user owned by a MasU. Plural SubUs. A SubU may itself be the MasU of further SubUs, so the users owned through one person form a tree.</li>
+        <li><RT·term>storage</RT·term>: a persistent storage device carrying users' data, such as the SSD inside a laptop or an encrypted USB drive. Its root is the directory it is mounted at. A storage may be moved between machines.</li>
+        <li><RT·term>SubU dir</RT·term>: the directory <RT·code>&lt;storage root&gt;/home/SubU</RT·code>, holding the storage database and the users' homes. On the disk a system runs from it is <RT·code>/home/SubU</RT·code>. SubU_admin is given a SubU dir to name a storage.</li>
+        <li><RT·term>system</RT·term>: an installed, running Linux, with its own <RT·code>/etc/passwd</RT·code>, its own uid numbering, and its own <RT·code>/home</RT·code>, into which the users kept on attached storage are mounted.</li>
+        <li><RT·term>storage database</RT·term>: the SQLite database in a SubU dir, describing the users kept on that storage.</li>
+        <li><RT·term>system database</RT·term>: the SQLite database at <RT·code>/var/lib/SubU/system.sqlite3</RT·code>, a system's own record. Its system map is the authority for which storage users have accounts on the system.</li>
+        <li><RT·term>database uuid</RT·term>: the random identifier a storage database is given when it is made. It names the database, not the storage: a copy of the database carries it too. It is not the storage uuid that a file system is given when it is formatted, which SubU does not use.</li>
+        <li><RT·term>system uuid</RT·term>: the random identifier a system database is given when it is made, which names the system.</li>
+        <li><RT·term>database uid</RT·term>: a user's uid in a storage database's own numbering. The user's files on the storage carry it.</li>
+        <li><RT·term>system uid</RT·term>: a user's uid on a system, in <RT·code>/etc/passwd</RT·code> there.</li>
+        <li><RT·term>user specifier</RT·term>: the name of a user in one place: <RT·code>system &lt;host name&gt; &lt;account&gt;</RT·code> for an account on a system, or <RT·code>storage &lt;SubU dir&gt; &lt;user&gt;</RT·code> for a user of a storage database.</li>
+        <li><RT·term>same user</RT·term>: two user specifiers that name one person's user in two places. The system map records which accounts are the same users as which storage users.</li>
+        <li><RT·term>view</RT·term>: an idmapped mount through which one user sees another user's files as its own.</li>
+        <li><RT·term>server</RT·term>: the program, running as root, that alone changes accounts, databases and mounts.</li>
+        <li><RT·term>unmanaged user</RT·term>: a user that SubU does not manage, such as <RT·code>root</RT·code>, or an account made with <RT·code>useradd</RT·code> by hand.</li>
+      </ul>
+
+      <h1>Users and names</h1>
+
+      <h2>Account names</h2>
+
+      <p>
+        A SubU's account name is its MasU's account name, the middle dot (U+00B7, written <RT·code>·</RT·code>), and the SubU's own short name: <RT·code>Thomas·developer</RT·code>, and below it <RT·code>Thomas·developer·scratch</RT·code>. The middle dot may not appear in a short name, so a full name divides in exactly one way, and the conventional characters <RT·code>_</RT·code>, <RT·code>-</RT·code> and <RT·code>.</RT·code> remain free for use in names.
+      </p>
+
+      <p>
+        The middle dot lies outside the POSIX portable character set. Account creation needs the <RT·code>--badname</RT·code> flag of <RT·code>useradd</RT·code>, and a tool that checks user names against the portable set needs its check widened. In UTF-8 the middle dot is two bytes, and a user name may currently be at most 32 bytes. That limit is set in user space, by the fixed name field of the <RT·code>utmp</RT·code> login records and by <RT·code>useradd</RT·code>, not by the kernel, which knows users only by uid. pkg-config prints paths holding the middle dot in a form that is not valid UTF-8; builds that pass such paths through pkg-config need the workaround given in <RT·code>shared/project-dependency/Python.org</RT·code>.
+      </p>
+
+      <h2>Identity of a user</h2>
+
+      <p>
+        A user has an identity in its database, and, when the storage the database is on is attached to a running system, an extended identity on that system.
+      </p>
+
+      <p>
+        In its database, a user is identified by the database uuid together with the user's database uid. The user's files on the storage are owned by that database uid, and a root user who mounts the storage by hand, without SubU, sees that database uid on them. This pair is all the database needs to identify the user.
+      </p>
+
+      <p>
+        On a running system, the user also needs a uid in the system's numbering, because that numbering is the one <RT·code>/etc/passwd</RT·code> and the kernel use there. A map row gives it. The row pairs the user's identity in its database with a uid on the system. The user's extended identity is its identity in its database, joined through the map to the system.
+      </p>
+
+      <h1>Storage and systems</h1>
+
+      <h2>Storage layout</h2>
+
+      <p>
+        Every storage has the same layout under its root, where the root is the directory the storage is mounted at: <RT·code>/</RT·code> for the disk a system runs from, or a mount point such as <RT·code>/mnt/Eagle</RT·code> for a drive plugged in. Its SubU dir is <RT·code>home/SubU</RT·code> under the root, so <RT·code>/home/SubU</RT·code> or <RT·code>/mnt/Eagle/home/SubU</RT·code>.
+      </p>
+
+      <RT·code>
+        home/SubU/database.sqlite3          the storage database
+        home/SubU/user_data/&lt;name&gt;      the home directory of each user carried, by account name
+        home/&lt;other&gt;                      anything else, such as the homes of unmanaged users
+      </RT·code>
+
+      <p>
+        Everything SubU keeps on a storage is under <RT·code>home/SubU</RT·code>, so the storage can carry other things beside it, and the storage database sits with the data it describes. <RT·code>user_data</RT·code> is the source of the users' data; on a running system each user's directory in it is reached through an idmapped mount at <RT·code>/home/&lt;name&gt;</RT·code>. A user named <RT·code>database</RT·code> cannot collide with the database, because the two are at different levels. The account name <RT·code>SubU</RT·code> is reserved, because on a running system <RT·code>/home/SubU</RT·code> holds SubU's own files.
+      </p>
+
+      <p>
+        On the disk a system runs from, SubU's files are therefore under <RT·code>/home</RT·code>, beside the homes of unmanaged users. Distributions already keep <RT·code>/home</RT·code> apart from the operating system: openSUSE on btrfs makes it a subvolume outside the root snapshots, so a rollback of the root leaves it alone, and installers on other file systems commonly offer it as a partition of its own, kept when the system is reinstalled. SubU's files inherit this without SubU depending on any file system. Were they on the root file system, a rollback of the root would roll back the storage database and the users' files with it.
+      </p>
+
+      <p>
+        A tool that walks <RT·code>/home</RT·code> sees each mounted user's files twice: at <RT·code>/home/&lt;name&gt;</RT·code>, and at <RT·code>/home/SubU/user_data/&lt;name&gt;</RT·code>. A bind mount of the same file system reports the same device number, so options such as <RT·code>rsync -x</RT·code> do not stop at the mount. A backup of <RT·code>/home</RT·code> excludes one of the two.
+      </p>
+
+      <h2>Every storage is the same</h2>
+
+      <p>
+        A storage is a repository of user data, whether it is built into the machine or plugged in, and a machine may have several. The SSD inside a laptop is attached at boot, before any login, in the same way a USB drive is attached when it is plugged in. Taken out of the laptop and put into a USB enclosure, the same SSD can be attached to another machine, and its users used there.
+      </p>
+
+      <h2>Copies</h2>
+
+      <p>
+        The database uuid names the database, not the storage it is on. A copy of <RT·code>home/SubU</RT·code>, made for a backup or an archive, carries the same database uuid, and is the same database until one of the two is changed. Because a SubU dir can be anywhere, an archive kept at <RT·code>/&lt;storage name&gt;/&lt;date&gt;/home/SubU</RT·code> can be attached by giving that SubU dir.
+      </p>
+
+      <p>
+        A storage moved between systems, plugged into a desktop, then a laptop, then the desktop again, is the ordinary case, and must attach without question. A copy that was used apart from its original must not be taken for it. The record of transactions tells the two apart, as git's history does:
+      </p>
+
+      <ul>
+        <li>Every attach and every detach is a transaction in the storage database. Two copies used apart therefore have different heads, even when no user was made or changed on either.</li>
+        <li>At each attach and detach, the system records in its <RT·code>storage_seen</RT·code> table the storage's head: its series number and hash.</li>
+        <li>At the next attach, the storage's record must hold that head. If it does, the storage is the one the system saw, moved on since, perhaps on another system, and it is attached. If it does not, the storage is an older copy, or a copy that went another way, and nothing is mounted unless the superuser gives the keyword <RT·code>accept-history</RT·code>.</li>
+        <li>Each row of the system map also records the storage's head when the row was made. A storage whose record does not hold it is not the storage that was mapped, and that user is not mounted.</li>
+      </ul>
+
+      <p>
+        The two heads are witnesses of different things. The head in <RT·code>storage_seen</RT·code> moves: it is where this system last saw the storage. A storage worked on at the desktop, then the laptop, then the desktop again, has a record that runs straight through: the desktop's last head is in it, followed by the laptop's transactions, and it attaches at the desktop without question. The system does not care how far along the storage has gone, only that it has not gone back, or sideways. The head in a system map row stays where it was put: it is the storage as it was when its user was made the same user as an account here, and every later state of that storage descends from it. It matters for a storage this system has not attached since the row was made, where <RT·code>storage_seen</RT·code> has nothing to say: a copy split off before the mapping, taken for the storage that was mapped, does not hold that head, and its user is not mounted.
+      </p>
+
+      <p>
+        A storage whose record does not hold the head this system last saw has gone back, to an older copy, or sideways, to a branch: two copies each changed after they parted. In 5.0 the superuser chooses one branch with <RT·code>accept-history</RT·code>, and it becomes the line this system follows; the other is set aside. Bringing the two branches together again is a merge. For the database itself, a merge replays the transactions of one branch onto the other where they do not conflict. For the users' files, it is the integration problem of 7.0, of which merging two branches of one storage is the simplest case.
+      </p>
+
+      <p>
+        These checks establish continuity, not authenticity. The hashes are not secret: they are in the storage database and the system database, and anyone who can read either can copy them into a fabricated database. Authenticity needs a secret, which is the subject of the keys planned for 6.0. Attach is also to refuse a database whose database uuid is already attached from another SubU dir; that check is not yet built.
+      </p>
+
+      <h2>Uids are a database's own</h2>
+
+      <p>
+        The uids in a database are that database's own numbering. Files on the storage carry them. A database never adopts the uids of a system it is attached to; its users are mounted onto the system through idmapped mounts, which translate the database's uids to the system's as the files are used. A storage can therefore be moved from machine to machine without changing its files.
+      </p>
+
+      <p>
+        A database gives out database uids counting down from 1999999999, while systems give out system uids counting up from low numbers. A database uid is therefore not also the system uid of an account, and a file on a storage, seen without its mount, does not appear to belong to a system user it does not belong to, except in the cases given under Limits of uids. The band 1879048192 to 2147352575 is left unused by systemd's allocation of uids, so a database has about 120 million database uids before it leaves the band. A user brought into SubU from an existing account can keep its existing uid, with the keyword <RT·code>uid</RT·code> of <RT·code>user make</RT·code>; for that user the coincidence remains, behind the root-only directories of <RT·code>home/SubU</RT·code>.
+      </p>
+
+      <p>
+        A database never reuses a database uid. A database uid given up by a deallocated SubU stays retired, because files carrying it may remain on the storage, in a backup, or on another storage they were copied to.
+      </p>
+
+      <h2>Limits of uids</h2>
+
+      <p>
+        Two numberings meet on a running system: database uids, on the files of storage, and system uids, in <RT·code>/etc/passwd</RT·code>. Each has limits, set by different parties, and each limit is checked where the party that sets it is known.
+      </p>
+
+      <p>
+        <em>The kernel.</em> Linux uids are 32 bits. 4294967295 is (uid_t)−1 and means no uid; 65534 is the overflow uid shown for a uid that cannot be mapped; neither is given out. A database uid is never used as a system uid. At run time it meets only the idmapped mount, which accepts any uid below 4294967295, so the kernel's limit is the same on every system and a storage moved between systems is unaffected by it.
+      </p>
+
+      <p>
+        <em>The file system.</em> ext4, xfs and btrfs store 32-bit uids. JFFS2 and Minix store 16 bits, and FAT and exFAT store none; such storage cannot carry SubU users. This limit belongs to the storage and travels with it, so it is checked once, when the storage is made: <RT·code>storage make</RT·code> sets the owner of a test file to the first database uid, reads it back, and refuses the storage if the two differ. That check is not yet built.
+      </p>
+
+      <p>
+        <em>The system.</em> <RT·code>UID_MAX</RT·code> in <RT·code>/etc/login.defs</RT·code> bounds only the uids <RT·code>useradd</RT·code> picks on its own; SubU gives <RT·code>useradd</RT·code> the system uid. A system's range of system uids is set by <RT·code>system make</RT·code>, 2000 to 59999 by default, and is the system's own. A storage moved to a system with a smaller range keeps its database uids, since they never become that system's uids; the smaller range limits only how many of the storage's users the system can give accounts to, and <RT·code>introduce</RT·code> reports when the range is used up.
+      </p>
+
+      <p>
+        <em>Where the two numberings can still meet.</em> A database uid equals the system uid of some account when:
+      </p>
+
+      <ul>
+        <li>a user was brought in with the keyword <RT·code>uid</RT·code> of <RT·code>user make</RT·code>, keeping its existing uid. This is deliberate.</li>
+        <li>a system's own range reaches into the band. <RT·code>system make</RT·code> refuses a range that reaches 1879048192.</li>
+        <li>accounts from outside SubU have uids in the band. A directory service mapping Active Directory accounts through SSSD gives out slices of 200000 uids up to 2000200000 by default, which is inside the band, and container uid ranges in <RT·code>/etc/subuid</RT·code> can be set anywhere.</li>
+        <li>a database has given out nearly all its band, after about 120 million users. <RT·code>user make</RT·code> refuses below 1879048192. Should a database ever need more, a later standard chooses the next band and records it in the database, so that databases already made keep theirs.</li>
+      </ul>
+
+      <p>
+        At attach, the database uids of the storage are to be compared with the system uids in use, from <RT·code>getent passwd</RT·code>, which includes directory services, and from <RT·code>/etc/subuid</RT·code>. Each coincidence is logged with the account that would appear to own the raw files. A coincidence is not refused: through the mounts each user sees its own files correctly, and the raw files are behind the root-only directories of <RT·code>home/SubU</RT·code>. It is reported because a tool that works by uid on the raw files, run as root, would treat them as that account's. This check is not yet built.
+      </p>
+
+      <p>
+        <em>Tools that copy files.</em> Backups and archives copy uids with the files. The old ustar format of tar holds uids up to 2097151, and the odc format of cpio up to 262143; database uids do not fit. GNU tar's default format, the POSIX pax format, cpio's newc format, rsync and btrfs send hold 32-bit uids, and archives of SubU storage use one of them.
+      </p>
+
+      <h2>The system</h2>
+
+      <p>
+        A system is a party in its own right, with its own database. Its system database, at <RT·code>/var/lib/SubU/system.sqlite3</RT·code>, holds its system uuid, the range of system uids SubU gives out, the system map, and the limits. The system owns the uid numbering of <RT·code>/etc/passwd</RT·code>: it alone decides which storage users get accounts on it and with which system uids, so that record is its own and not any storage's. A storage plugged in can claim nothing; what it names in its own database grants it no account.
+      </p>
+
+      <p>
+        A system never reuses a system uid. Its own disk keeps files carrying system uids, in <RT·code>/tmp</RT·code>, <RT·code>/var/spool</RT·code>, crontabs and logs, and a reused uid would hand them to another user. The system map therefore keeps every row it has held, retired rows included, and <RT·code>is-same-user</RT·code> with <RT·code>make-account</RT·code> never gives an account a system uid that appears in it.
+      </p>
+
+      <p>
+        The system database is under <RT·code>/var</RT·code>, which holds the state programs keep, and not under <RT·code>/etc</RT·code>, which holds configuration. A system whose root file system is snapshotted, as openSUSE's is, keeps <RT·code>/var</RT·code> outside the snapshots. A rollback of the root then rolls back <RT·code>/etc/passwd</RT·code> and leaves the system map as it was: an account made after the snapshot is lost from <RT·code>/etc/passwd</RT·code>, but its system uid stays recorded, is not given out again, and the account can be made again from the system map. Were the system database under <RT·code>/etc</RT·code>, the rollback would also forget the system uid, and it could be given to another user while files carrying it remained under <RT·code>/var</RT·code>.
+      </p>
+
+      <h2>Deleting storage</h2>
+
+      <p>
+        <RT·code>SubU_admin storage delete &lt;SubU dir&gt;</RT·code> undoes, on this system, what SubU did for the users of the storage database, then deletes the SubU dir. It makes every check first, and a failed check changes nothing:
+      </p>
+
+      <ul>
+        <li>the storage database's notes name no other system, since that system's map would name a database that no longer exists, unless the keyword <RT·code>forget-other-systems</RT·code> is given;</li>
+        <li>no account that is the same user as one of its users is logged in;</li>
+        <li>nothing is mounted inside the SubU dir;</li>
+        <li>the SubU dir holds only the storage database and <RT·code>user_data</RT·code>, and <RT·code>user_data</RT·code> holds only homes of the database's users;</li>
+        <li>homes that hold files are deleted only when the keyword <RT·code>delete-user-data</RT·code> is given.</li>
+      </ul>
+
+      <p>
+        For each row of this system's map naming one of its users, it then unmounts the home, deletes the mount point attach made, recognized as an empty directory owned by root with mode 0000, deletes the account and its group when SubU made them, keeps an adopted account, and retires the row. Last it deletes <RT·code>user_data</RT·code>, the storage database and the SubU dir. Run a second time, it finds nothing to delete and says so. <RT·code>system delete</RT·code> deletes the system database, and is refused while any row of the system map is in use.
+      </p>
+
+      <h1>The databases</h1>
+
+      <p>
+        There are two kinds of database, with two schemas, <RT·code>schema_storage.sql</RT·code> and <RT·code>schema_system.sql</RT·code>. Both keep the same record of changes, described below, and are checked the same way. Each fact is kept by the party with authority over it: a storage database keeps its users and their database uids; the system database keeps which of those users have accounts on the system.
+      </p>
+
+      <h2>Storage database tables</h2>
+
+      <ul>
+        <li><RT·code>identity</RT·code>: the database uuid, the series number, the head hash, and when the database was made. One row. A storage database has no name for people. A name would travel with every copy of the database, and a name taken from the storage it was made on would come to mislead once the database was copied elsewhere. A database is shown by its database uuid, its series number, its head hash and the time of its last change, and log lines give the first eight digits of the database uuid.</li>
+        <li><RT·code>standard</RT·code>: the SubU standards this database follows, one row each. Currently <RT·code>5.0</RT·code>.</li>
+        <li><RT·code>user</RT·code>: one row per user carried: database uid, user name, the database uid and user name of its MasU, state (<RT·code>active</RT·code>, <RT·code>deprecated</RT·code>, <RT·code>deallocated</RT·code>), and times. The MasU's database uid is empty for a user at the top of a tree, or one whose MasU is carried by another database.</li>
+        <li><RT·code>database_uid</RT·code>: every database uid this database has ever given out, with whether it is in use or retired.</li>
+        <li><RT·code>system_seen</RT·code>: the systems that have seen this database: those on which a user of it has been made the same user as an account, with when that was first and when it was last attached or detached there. For the owner of the storage, and for <RT·code>storage delete</RT·code>, which will not leave a system mapping a database that no longer exists. Never authority.</li>
+        <li><RT·code>acquaintance_database</RT·code> and <RT·code>same_user_database</RT·code>: other storage databases this one knows, and which of their users are the same users as users here, by database uid. Written into both databases. Not used in 5.0.</li>
+        <li><RT·code>transaction_log</RT·code>: the record of every change, described below.</li>
+      </ul>
+
+      <h2>System database tables</h2>
+
+      <ul>
+        <li><RT·code>identity</RT·code>: the system uuid, the series number, the head hash, and when the database was made. One row. The system is shown by its host name, read when it is shown, so a name for people is not kept.</li>
+        <li><RT·code>standard</RT·code>: as for a storage database.</li>
+        <li><RT·code>system_uid_range</RT·code>: the first and last system uid SubU gives an account it makes. One row.</li>
+        <li><RT·code>system_map</RT·code>: one row for each time an account has been made the same user as a storage user: the system uid and gid, the account name, the database uuid and database uid, whether SubU made the account or adopted one that existed, whether the row is in use or retired, when, and the storage database's series number and head when the row was made. At most one row in use names a given system uid, and at most one names a given storage user. A row is retired, never deleted. An adopted account may be made the same user again once its row is retired; a system uid that appears in any row is never given to an account SubU makes.</li>
+        <li><RT·code>storage_seen</RT·code>: for each storage database, its series number and head when this system last attached or detached it.</li>
+        <li><RT·code>user_limit</RT·code>: the number of SubUs each account may have below it, by system uid. An account with no row may have none.</li>
+        <li><RT·code>transaction_log</RT·code>: the record of every change.</li>
+      </ul>
+
+      <h2>Series number and hash chain</h2>
+
+      <p>
+        A transaction is one change to one database: the SQL statements a command of <RT·code>SubU_admin</RT·code>, or a request to the server, runs against that database, recorded together. A command can make transactions in both kinds of database; <RT·code>is-same-user</RT·code> makes one in the storage database and one in the system database.
+      </p>
+
+      <p>
+        Each transaction increments the series number, and records its statements, a description, a random number, and a hash. The hash is taken over the previous transaction's hash, the series number, the description, the statements, and the random number. The first transaction follows a hash made from the database's kind and uuid, so no two databases' chains can coincide, and a record cannot be moved from one database into another. The <RT·code>identity</RT·code> row holds the series number and hash of the last transaction, the head of the chain.
+      </p>
+
+      <p>
+        The chain gives order, integrity and identity of history. It does not give authenticity. Anyone holding a database can extend its chain, or rewrite it and compute new hashes. What a rewrite cannot do is keep the hashes that others have already seen: a system that remembers a head, or a system map row that recorded one, finds the rewritten record does not hold it. Each party that remembers a head is a witness. A storage database and the system database are readable by root only, so the record is the database's own history, as a git repository's history is, rather than a log for its users to read.
+      </p>
+
+      <p>
+        The series number orders a database's history; the hash identifies it. A copy that is used goes on to make its own transactions, with the same series numbers as the original's and different contents. Their hashes differ, because the random number differs even when the same change is made to both. So two databases with the same database uuid and series number but different heads are known to have forked, and neither is taken to be the other.
+      </p>
+
+      <p>
+        Times recorded in a database are for people to read. They are not authoritative, because clocks differ between machines. The series number is authoritative.
+      </p>
+
+      <h2>Standards</h2>
+
+      <p>
+        A program that opens a database first reads its <RT·code>standard</RT·code> table, and refuses a database that follows no standard the program follows. A database may list more than one standard, during a change from one to the next.
+      </p>
+
+      <h2>Fallback tools</h2>
+
+      <p>
+        The databases can be built by hand from SQL scripts kept in the project. They are the means of starting over if a database is lost or damaged.
+      </p>
+
+      <ul>
+        <li>A schema script builds an empty database where there was none.</li>
+        <li>Initialization scripts put in the starting data: the identity row, the standard, existing users, the uid range, and limits.</li>
+        <li>Scripts with their data written into them can write the system map rows for existing accounts, for a system whose system database is lost.</li>
+      </ul>
+
+      <h1>Same users</h1>
+
+      <p>
+        A user's directory on a storage, <RT·code>home/SubU/user_data/&lt;name&gt;</RT·code>, carries only its database uid. What lets it be used on a system is a row of that system's map, saying that an account there is the same user as the storage user. The superuser states it:
+      </p>
+
+      <RT·code>
+        SubU_admin is-same-user system &lt;host name&gt; &lt;account&gt; storage &lt;SubU dir&gt; &lt;user&gt; [make-account]
+      </RT·code>
+
+      <p>
+        A keyword such as <RT·code>make-account</RT·code> is a word given after a command's other arguments, as the command itself is a word, not a flag. The two user specifiers may be given in either order. In 5.0 one names an account on this system and the other a storage user; correspondences between two storage databases come later. The host name must be this system's, which confirms that the superuser is on the machine he means. When the account exists, it is adopted: it must belong to a person, with a system uid at or above <RT·code>UID_MIN</RT·code>, its home must be <RT·code>/home/&lt;account&gt;</RT·code>, and no other storage user may already be the same user as it. When it does not exist, the keyword <RT·code>make-account</RT·code> makes it, with a system uid from the system's range that no account, no group and no row of the system map has had. Without the keyword the command refuses, so that a mistyped name does not make an account.
+      </p>
+
+      <p>
+        The first time a storage user is made the same user as an account on a system, the storage database notes that system in <RT·code>system_seen</RT·code>. That note and the system map row together are what 5.0 calls an introduction; in 6.0 it is also when keys are exchanged. <RT·code>user make</RT·code> with <RT·code>make-account</RT·code> makes a storage user and, in the same command, an account of the same name, the same user as it.
+      </p>
+
+      <p>
+        <RT·code>is-not-same-user</RT·code> undoes it: it unmounts the home if it is mounted from that storage, deletes the mount point attach made, deletes the account if SubU made it and keeps an adopted one, and retires the row. <RT·code>same-user show</RT·code> lists who is the same user as whom, with the storage users and person accounts that are not. <RT·code>same-user suggest</RT·code> prints <RT·code>is-same-user</RT·code> commands proposed by matching names, for the superuser to read and run; it changes nothing. Matching names is a guess, and is used nowhere else.
+      </p>
+
+      <p>
+        Someone else's storage, plugged into this system, gets nothing until the superuser makes its users the same users as accounts here. There is no guest attach in 5.0: every storage is introduced by the superuser. A user named <RT·code>Thomas</RT·code> on that storage is no one here until the system map says so.
+      </p>
+
+      <p>
+        Two storage databases may say that a user of one is the same user as a user of the other, and they may say it while a system's map says otherwise. Such a claim grants nothing until a system acts on it, as in mounting a MasU's view of a SubU kept on other storage. A system acts only on its own record: it does not infer that two accounts are the same user from what storage databases say, and where the claims of storage databases and its own map disagree, it reports the disagreement. A SubU made through the server on this system was made by this system, which records the MasU relation itself.
+      </p>
+
+      <p>
+        In 5.0 storage databases and systems recognize each other by database uuid and system uuid alone. A copy of a storage database is taken for the original, and gains the accounts mapped to the original's users. That is enough among one person's machines and storage. It is not enough among strangers, because either kind of uuid can be copied; the migration to a cryptographic check is given under standard 6.0.
+      </p>
+
+      <h1>The server</h1>
+
+      <h2>What it is</h2>
+
+      <p>
+        The server is the one program that changes accounts, databases and mounts. It runs as root, as a system service, and takes requests from any user over a Unix domain socket, <RT·code>/run/SubU/socket</RT·code>. The socket is open to every user; the server decides what each request may do.
+      </p>
+
+      <p>
+        The server handles one request at a time. Two requests can then never give out the same uid or the same name, and no locking is needed.
+      </p>
+
+      <h2>Who is asking</h2>
+
+      <p>
+        The server learns the requesting user from the kernel. After a client connects, the server reads the <RT·code>SO_PEERCRED</RT·code> socket option, which gives the process id, uid and gid the kernel recorded for the client when it called <RT·code>connect</RT·code>. The client does not send them, so it cannot misstate them. The server authorizes by the uid and never by the process id, because process ids are reused. The uid gives the account name through <RT·code>getpwuid</RT·code>, and the account name is the prefix of any SubU the user makes.
+      </p>
+
+      <h2>Requests</h2>
+
+      <p>
+        Each request is checked, carried out as a database transaction and the system changes that go with it, and answered. A user may act on itself and on any user in the tree below it, with the authority of the requesting user; this is how a SubU's window, opened from its MasU's, acts with the MasU's authority.
+      </p>
+
+      <ul>
+        <li><RT·code>make</RT·code> &lt;short name&gt;: make a SubU of the requesting user. Checks: the short name is valid and not in use; the requesting user's limit, and the limit of every user above it in its tree, allow one more. The SubU is made in the requesting user's database unless another attached database is named. Steps: a database uid, never given before, and the user row in the storage database; a system uid, never given before, and the row in the system map; the account in <RT·code>/etc/passwd</RT·code>, through <RT·code>useradd --badname</RT·code>; the home directory in <RT·code>home/SubU/user_data</RT·code>, owned by the database uid, mode 0700; the mount of it at <RT·code>/home/&lt;name&gt;</RT·code>; the MasU's view.</li>
+        <li><RT·code>rename</RT·code> &lt;user&gt; &lt;new short name&gt;: rename a SubU. The SubU, and every SubU below it, whose names begin with its name, must be logged out; all are renamed together. The uids and files do not change; the home directories, mount points and views are renamed with them.</li>
+        <li><RT·code>deprecate</RT·code> &lt;user&gt;: end the SubU's sessions, lock its account, and mark it deprecated. Its data, name and uid are kept. It still counts against the limits. A deprecated SubU can be restored.</li>
+        <li><RT·code>deallocate</RT·code> &lt;user&gt;: remove a deprecated SubU. Its account and views are removed, and its database uid and system uid are retired. What becomes of its data is open: the proposal is to move it to a holding directory on the storage, from which only the superuser removes it.</li>
+        <li><RT·code>view</RT·code> [&lt;user&gt;]: report a user and its tree: names, states, storage, and whether each is mounted. Changes nothing.</li>
+        <li><RT·code>set views</RT·code>: mount the requesting user's views of its SubUs that are on attached storage. Views already mounted are left as they are.</li>
+        <li><RT·code>reconcile</RT·code>: compare the databases on attached storage with <RT·code>/etc/passwd</RT·code> and the mount table, complete what an interrupted request left undone, and report what differs. Run at start and on demand. Superuser only.</li>
+      </ul>
+
+      <p>
+        The superuser's commands are not requests. <RT·code>SubU_admin</RT·code> runs as root and calls the work functions itself, so making users the same users, attaching, detaching and setting limits work while the server is stopped, at boot for instance. The server and <RT·code>SubU_admin</RT·code> may both write a database; SQLite's lock lets one transaction in at a time.
+      </p>
+
+      <h2>Partial failure</h2>
+
+      <p>
+        A request makes several changes, and any of them can fail after the earlier ones succeeded. The databases record the intended state of each user. A failure leaves the intended state recorded and the system short of it; <RT·code>reconcile</RT·code> finishes the work or reports why it cannot. A request is complete only when the system matches the databases.
+      </p>
+
+      <h2>Protocol</h2>
+
+      <p>
+        A request is one JSON object on one line, carrying the protocol version, the request name, and its arguments. The answer is one JSON object on one line, carrying whether the request succeeded, its result, and on failure a message for a person to read.
+      </p>
+
+      <h2>The service unit</h2>
+
+      <p>
+        The server's systemd unit must not set <RT·code>PrivateMounts</RT·code>, <RT·code>ProtectHome</RT·code> or any other option that gives the service a mount namespace of its own. A mount made inside such a namespace exists only for the service, and the users never see it.
+      </p>
+
+      <h1>Limits</h1>
+
+      <p>
+        Making a SubU takes a database uid from a storage database and a system uid and an account from the system, and the system's are finite. Without a limit, a user could take all of them. Each user has a limit on the number of SubUs in its tree, counting SubUs at every depth and counting deprecated SubUs. Limits are kept in the system database. The superuser sets them with <RT·code>SubU_admin limit set</RT·code>. A user with no limit set has a limit of 0, and cannot make SubUs. Any user with a limit above 0 can make SubUs.
+      </p>
+
+      <p>
+        A SubU made by a SubU counts against the limits of every user above it. The starting value suggested for a person is 100.
+      </p>
+
+      <h1>Mounts</h1>
+
+      <h2>Idmapped mounts</h2>
+
+      <p>
+        Every SubU mount is an idmapped mount: a bind mount that carries a translation of uids, applied by the kernel to every permission check and to every file created through it. Idmapped mounts need Linux 5.12 or later, and a file system that supports them, such as ext4, btrfs or xfs. An idmapped mount is kernel state, with no process behind it, so it survives the restart of the server and the end of any user's session. A mount through FUSE, such as bindfs, does neither.
+      </p>
+
+      <h2>Attaching storage</h2>
+
+      <p>
+        <RT·code>SubU_admin attach &lt;SubU dir&gt;</RT·code> verifies the storage database's standards and hash chain, checks its continuity as described under Copies, then reads this system's map for the rows naming that database uuid, and nothing else. For each, it checks that the storage user is active and that the account exists with its system uid and with its home at <RT·code>/home/&lt;account&gt;</RT·code>, and mounts the user's directory in <RT·code>user_data</RT·code> there, translating the database uid to the system uid. Storage users with no row are logged as skipped. The mounts are <RT·code>nosuid</RT·code> and <RT·code>nodev</RT·code>, since a storage can carry set-user-id programs and device files. Files on the storage owned by database uids the mount does not translate appear owned by <RT·code>nobody</RT·code>.
+      </p>
+
+      <h2>Views</h2>
+
+      <p>
+        A MasU sees each of its SubUs at <RT·code>/home/&lt;MasU&gt;/SubU/&lt;short name&gt;</RT·code>: an idmapped mount of the SubU's home that translates the SubU's uid to the MasU's. Files there appear to be the MasU's, and files the MasU creates there belong to the SubU. Views are set when storage is attached, for the MasUs already logged in, and at each login, through <RT·code>set views</RT·code>.
+      </p>
+
+      <h2>Mount points</h2>
+
+      <p>
+        A mount point must exist before a mount, so <RT·code>/home/&lt;name&gt;</RT·code> exists for every known user, attached or not. While nothing is mounted on it, it is an empty directory owned by root with mode 0000, and marked immutable with <RT·code>chattr +i</RT·code>. No user can enter it, and nothing can be written into it to be hidden later by the mount.
+      </p>
+
+      <h1>Login</h1>
+
+      <p>
+        A SubU whose storage is not attached has no home. A login into the empty mount point finds no initialization files, and does not read them later when the storage arrives. Login is therefore refused for a managed user whose home is not mounted.
+      </p>
+
+      <p>
+        The check is in PAM, which every way of logging in passes through: the display manager, the console, ssh, <RT·code>su</RT·code> and <RT·code>machinectl shell</RT·code>.
+      </p>
+
+      <RT·code>
+        account sufficient pam_succeed_if.so uid = 0 quiet
+        account required   pam_exec.so quiet /usr/local/lib/SubU/home_present
+      </RT·code>
+
+      <p>
+        <RT·code>home_present</RT·code> allows the login when the user is not managed by SubU, or when <RT·code>/home/&lt;name&gt;</RT·code> is a mount point, and refuses it otherwise. The first line keeps root able to log in whatever the check does.
+      </p>
+
+      <p>
+        A user session, when it starts, sends <RT·code>set views</RT·code>. The server knows the user from the socket, so a session can set only its own views.
+      </p>
+
+      <h1>Programs</h1>
+
+      <ul>
+        <li><RT·code>SubU</RT·code>: the user's program. Its commands send requests to the server; its <RT·code>window</RT·code> command opens the resource window, which shows the user's SubUs and their states and sends the same requests. Runs as the user.</li>
+        <li><RT·code>SubU_admin</RT·code>: the superuser's program. Root. Its commands: <RT·code>system make</RT·code>, <RT·code>system show</RT·code> and <RT·code>system delete</RT·code>; <RT·code>storage make</RT·code>, <RT·code>storage show</RT·code> and <RT·code>storage delete</RT·code>, each given a SubU dir; <RT·code>user make</RT·code>; <RT·code>is-same-user</RT·code>, <RT·code>is-not-same-user</RT·code>, <RT·code>same-user show</RT·code> and <RT·code>same-user suggest</RT·code>; <RT·code>attach</RT·code> and <RT·code>detach</RT·code>; and <RT·code>limit set</RT·code>. At boot, before logins are allowed, <RT·code>attach</RT·code> is run for every storage present, the disk inside the machine included, so that <RT·code>/home</RT·code> is populated. Detaching is refused while an account it would unmount is logged in. Unlocking an encrypted storage and mounting it come later.</li>
+        <li><RT·code>SubU_server</RT·code>: the server, started by its systemd unit. Nobody types it.</li>
+        <li><RT·code>home_present</RT·code>: the PAM check.</li>
+        <li>The schema and initialization scripts.</li>
+      </ul>
+
+      <p>
+        Each program keeps the RT convention of a command line interface apart from its work functions. There are two layers of work functions. Those that change the system run as root; <RT·code>SubU_admin</RT·code> and the server import them. The user's functions send requests to the server and return its replies; <RT·code>SubU</RT·code> and the window import them. The socket between the layers is where privilege changes. Keeping the root functions separate lets them later be moved into a smaller program of their own, or into the kernel.
+      </p>
+
+      <h1>Open questions</h1>
+
+      <ul>
+        <li>What deallocation does with a SubU's data.</li>
+        <li>What <RT·code>SubU_admin attach</RT·code> does when it finds a forked database, beyond refusing to attach it.</li>
+        <li>Whether a MasU's views are removed when its last session ends, or left until the storage is detached.</li>
+        <li>How resources other than accounts and storage, such as network zones and devices, are recorded in the database.</li>
+      </ul>
+
+      <h1>Road map</h1>
+
+      <p>
+        Standard 5.0 is the design above. Later standards add the following, each described in its own section. A database lists the standards it follows in its <RT·code>standard</RT·code> table, so a database moves to a later standard by listing it, and a program that follows only an earlier one refuses it.
+      </p>
+
+      <ul>
+        <li>6.0: longer user names; keys, for unlocking storage and recognizing it; signed heads; deduplication; deep freeze.</li>
+        <li>7.0: integration of archives and of child copies, and merging of branches; a property file system.</li>
+        <li>Later, version not yet assigned: guests.</li>
+      </ul>
+
+      <p>
+        <em>Guests.</em> A person who plugs storage into someone else's machine and works there, with no administrator introducing it first, makes the machine a display with a processor for whatever storage is brought to it. The guest's users would be given accounts at attach, in a band of system uids of their own, recorded in the system map as transient, and deleted at detach, with no rights to anything of the machine's owners. In 5.0 every storage is introduced by the superuser. Guests wait until keys, automatic attach, and the protection of a machine from what a stranger's storage carries, are understood.
+      </p>
+
+      <h1>Standard 6.0</h1>
+
+      <h2>Longer user names</h2>
+
+      <p>
+        Account names built from a chain of MasU names grow long, and 32 bytes, with two for each middle dot, is soon reached. 6.0 is to allow names of 128 bytes. The limit is in user space: the login records and <RT·code>useradd</RT·code>. Distributions are replacing the <RT·code>utmp</RT·code> records with <RT·code>wtmpdb</RT·code> and <RT·code>lastlog2</RT·code>, which have no fixed name field; openSUSE has done so. What remains is the check in <RT·code>useradd</RT·code>, which the SubU project may carry a patch for. Whether a system already accepts longer names is found by making a test account with a name past 32 bytes.
+      </p>
+
+      <h2>Keys</h2>
+
+      <p>
+        In 5.0, databases and systems recognize each other by database uuid and system uuid. 6.0 adds two uses of keys.
+      </p>
+
+      <p>
+        <em>Unlocking storage.</em> A storage's file system is encrypted with LUKS2, which keeps up to 32 key slots, any of which opens it. Each person who owns users on the storage has a slot, opened by a passphrase or by a hardware token such as a FIDO2 key. When a storage is introduced to a system, the superuser opens it once, and the system adds a slot of its own, with a key it keeps, so that the storage is opened and attached on that system without asking anyone for a password. systemd-cryptenroll already enrolls such slots, and can seal a key in the machine's TPM, the security chip on its motherboard, so that the key is useless away from that machine. A slot can be removed from any machine that can open the storage, which is how a stolen laptop is cut off from it. Opening a storage with a key the system enrolled is also the proof that it is the storage that was introduced, which uuids and hashes cannot give: a fabricated storage cannot be opened with that key. The keys a system keeps make its system database, or wherever the keys are kept, a target in the way {C('/etc/shadow')} is; sealing them in the TPM is the protection. The disk inside the machine can be treated the same way, its {C('/home')} opened by the system's own slot at boot, which keeps every storage alike.
+      </p>
+
+      <p>
+        <em>Signed heads.</em> Each database and each system also holds a key pair. Each transaction's hash, which covers every transaction before it, is signed with the private key of the party that wrote it, so that a head can be shown to have been written by whoever holds that key, and not only to follow from the heads before it. The signature is checked with the public key, and the first is-same-user between a system and a storage database records each one's public key in the other's database, alongside its database uuid or system uuid. On attach, each side checks that the other holds the private key matching the public key recorded for it, so a copied database uuid or system uuid alone is refused. A storage is not a running party, so a database's private key is a file on its storage, and a full copy of the database copies the key too; protecting the key, for example inside the storage's encryption, is part of the 6.0 design.
+      </p>
+
+      <p>
+        The migration touches only the acquaintance table and the programs that read it. A 6.0 database adds the key columns and lists both <RT·code>5.0</RT·code> and <RT·code>6.0</RT·code> in its standard table while their keys are exchanged. The system map, uids and the user table are unchanged, because a user is still identified by its database and its uid in that database.
+      </p>
+
+      <h2>Deduplication</h2>
+
+      <p>
+        A storage gathers copies of the same files, from old archives and from copies made between users. On btrfs, two files with the same content can be made to share their extents, after which the content is stored once while the files stay separate: a later write to one of them is written apart from the other. The kernel call that does this, <RT·code>FIDEDUPERANGE</RT·code>, compares the two ranges byte for byte before sharing them, so a false match on a hash cannot corrupt data. Tools such as duperemove and bees find candidates by hashing and leave the comparison to that call.
+      </p>
+
+      <p>
+        The SubU server already runs as a service, and is a natural home for a background pass over attached storage. The pass keeps an index of content hashes, gives the kernel call the pairs whose hashes match, and so keeps each storage compacted as it is used. On a file system without shared extents, the pass reports duplicates and changes nothing. The same index serves integration in 7.0.
+      </p>
+
+      <h2>Deep freeze</h2>
+
+      <p>
+        A person's SubUs, archives included, may not fit on one portable storage: 500 GB, say, against more data than that. Most of it is seldom read. Deep freeze divides a SubU into a working part and a frozen part by access time. Files not read for a long time move to the frozen part, which is kept on other storage, and a copy of a SubU to a storage that cannot hold all of it takes the working part only. Whether the frozen part's directory tree stays visible, with its content brought in when its storage is attached, is open.
+      </p>
+
+      <p>
+        Access times are recorded only when a file system is mounted with <RT·code>atime</RT·code> or <RT·code>relatime</RT·code>. <RT·code>relatime</RT·code>, the Linux default, updates a file's access time at most once a day, which is enough here; under <RT·code>noatime</RT·code> nothing is recorded, and deep freeze cannot work. Bringing a working part back after it was changed elsewhere is the integration problem of 7.0.
+      </p>
+
+      <h1>Standard 7.0</h1>
+
+      <h2>Integration</h2>
+
+      <p>
+        Two kinds of integration are needed. The first brings the files of old archives into the current SubUs. The old archives have older, less mature directory structures, and the aim is to place their files in the current structure, not to keep the old structures. The content-hash index kept for deduplication shows which old files already exist in the current tree, under any name, and leaves the rest to be placed.
+      </p>
+
+      <p>
+        The second brings back a child copy: part of a SubU copied to a portable storage, changed there, and returned. Its changes are merged into the original. A change to the directory structure on either side is the hard case, as when a file is moved on one side and changed on the other.
+      </p>
+
+      <h2>Property file system</h2>
+
+      <p>
+        Both kinds of integration are hard because a file has one place in one tree. A file system organized by properties, or tags, would let a file belong to an old structure and a new one at once, and make a directory a query. SubU's contract with storage is that a user's home is a directory mounted at <RT·code>/home/&lt;name&gt;</RT·code>, and the mounting is one function in the code. A home provided by a property file system changes that function and nothing else, provided the file system supports idmapped mounts or translates uids itself. At that point the SubU project is expected to fork.
+      </p>
+
+
+    </RT·article>
+  </body>
+</html>
diff --git a/setup b/setup
index 8ab56c8..1992fea 100755 (executable)
--- a/setup
+++ b/setup
@@ -17,8 +17,22 @@ def main():
 
     env = os.environ.copy()
 
+    # A role shell started inside another role shell inherits the outer
+    # project's PATH, REPO_HOME and functions, and a loop through setup would
+    # start shells without end. RT_SETUP_DEPTH counts the role shells this
+    # process is inside; any nesting is refused.
+    depth = env.get("RT_SETUP_DEPTH", "0")
+    depth = int(depth) if depth.isdigit() else 0
+    if depth > 0:
+        print(f"setup: already inside a role shell ({env.get('PROJECT', '?')} {env.get('ROLE', '?')}).")
+        print("setup: type 'exit' to leave it, then run setup again.")
+        sys.exit(1)
+    env["RT_SETUP_DEPTH"] = str(depth + 1)
+
     # Python-domain logic: Variables and Paths
-    repo_home = env.get("REPO_HOME", os.getcwd())
+    #   The project is the directory holding this file, whatever REPO_HOME the
+    #   calling shell carries; an inherited REPO_HOME may name another project.
+    repo_home = os.path.dirname(os.path.realpath(__file__))
     role = args.role
     role_home = os.path.join(repo_home, role)
     tool_dir = os.path.join(role_home, "tool")
@@ -31,7 +45,7 @@ def main():
     print(f"ROLE_HOME {role_home}")
 
     # Python-domain logic: Directory validation
-    if not os.path.isdir(role):
+    if not os.path.isdir(role_home):
         print(f"setup: failed to locate directory for role '{role}'")
         sys.exit(1)
 
@@ -61,6 +75,7 @@ def main():
     with os.fdopen(fd, "w") as f:
         f.write("\n".join(bash_logic) + "\n")
 
+    os.chdir(repo_home)
     os.execvpe("bash", ["bash", "--rcfile", rcfile_path], env)
 
 if __name__ == "__main__":
index eddbaad..f0b108d 100644 (file)
@@ -3,14 +3,23 @@
 
 # Python.init
 # --------------------------------------------------------------------------------
-# Ensure the local Python build is prioritized in the PATH.
-# The user must establish the Python-3.12.3 symlink for this to activate.
+# Put the project's Python build at the front of PATH, but only when it runs.
+#
+# Every '#!/usr/bin/env python3' script the person runs, their own tools
+# included, takes the first python3 on PATH. A build that cannot start, for
+# example one that lost its libpython after its directory moved, must not be
+# put there, or all of those scripts fail with it. The test runs the
+# interpreter once with an empty program.
 
-if [[ -d "$REPO_HOME/shared/project-dependency/Python-3.12.3/bin" ]]; then
-  PATH="$REPO_HOME/shared/project-dependency/Python-3.12.3/bin:$PATH"
-else
-  # Optional: Issue a quiet warning for the administrator role
+python_dp="$REPO_HOME/shared/project-dependency/Python-3.12.3"
+if [[ ! -e "$python_dp" ]]; then
   if [[ "$ROLE" == "administrator" ]]; then
-     echo "Notice: Local Python-3.12.3 bin not found. Using system Python."
+    echo "Python.init: no $python_dp; using the system python3"
   fi
+elif "$python_dp/bin/python3" -c '' 2>/dev/null; then
+  PATH="$python_dp/bin:$PATH"
+else
+  echo "Python.init: $python_dp/bin/python3 does not run; using the system python3" >&2
+  echo "Python.init: run it directly to see why, and see Python.org beside this file" >&2
 fi
+unset python_dp
index a5ba50c..4799f37 100644 (file)
@@ -1,10 +1,85 @@
-#+TITLE: Python Installation Guide
+#+TITLE: Python for SubU
 #+AUTHOR: Project Administrator
 
-This project requires a dedicated Python environment. The configuration plugin (`shared/project-dependency/Python.init`) was written targeting Python 3.12.3, though the architecture does not rely on any known version-dependent features. 
+SubU's tools are Python programs, and its window is drawn with GTK 4 through PyGObject, the GTK bindings for Python. GTK talks to the desktop's display directly, Wayland or X, as the desktop's own programs do.
 
-* 1. Download and Extract
-Download the Python source code and extract it as a sibling to the main project directory. Assuming you are at the top level of your project workspace:
+There are two ways to provide the Python. Choose one.
+
+  - The system Python. Least work: install two packages, section 1, and stop there.
+  - A project Python, built from source as a sibling of the project and linked into it. The project then does not depend on the system's Python version. Build the vanilla Python of section 3 first, then add what SubU needs, section 2.
+
+The role setup decides which one a shell gets. shared/project-dependency/Python.init puts the project Python at the front of PATH when shared/project-dependency/Python-3.12.3 leads to a build that runs. When there is no such build, or it does not run, python3 is the system's. Running the tools with the system Python therefore needs nothing more than not installing the project Python.
+
+The package names below are for openSUSE; zypper search finds the name of one that differs on another release, and other distributions name the same libraries similarly.
+
+* 1. Using the system Python
+
+Install PyGObject for the system Python, and the GTK 4 introspection data. Introspection data is the machine-readable description of GTK's interface, which PyGObject reads at run time to call into GTK.
+
+#+begin_src bash
+sudo zypper install python313-gobject typelib-1_0-Gtk-4_0
+#+end_src
+
+The 313 in the first name is the system Python's version, 3.13; find yours with python3 --version.
+
+Check:
+
+#+begin_src bash
+python3 -c 'import gi; gi.require_version("Gtk" ,"4.0"); from gi.repository import Gtk; print("GTK" ,Gtk.get_major_version())'
+#+end_src
+
+* 2. SubU's additions to the project Python
+
+A Python built from source has only its standard library. The system's PyGObject cannot be borrowed: it is installed in the system Python's own module directory, and is compiled for that Python's version. The project Python gets its own copy, compiled by pip against the GTK libraries already on the system.
+
+** 2.1 System development files
+
+pip compiles PyGObject and pycairo, the Python bindings for cairo, the drawing library GTK uses. Compiling needs the C development files of both libraries; the libraries themselves are already present on any desktop that runs GTK programs. The GTK 4 introspection data is read when the window opens.
+
+#+begin_src bash
+sudo zypper install cairo-devel gobject-introspection-devel typelib-1_0-Gtk-4_0
+#+end_src
+
+** 2.2 Install PyGObject
+
+Run this in a role shell, so that python3 is the project Python, and with the Python's pkg-config files moved aside for the length of the install:
+
+#+begin_src bash
+mv shared/project-dependency/Python-3.12.3/lib/pkgconfig shared/project-dependency/Python-3.12.3/lib/pkgconfig.off
+python3 -m pip install PyGObject
+mv shared/project-dependency/Python-3.12.3/lib/pkgconfig.off shared/project-dependency/Python-3.12.3/lib/pkgconfig
+#+end_src
+
+Why the move. The build system pycairo uses, Meson, asks pkg-config where Python's header files are, and pkg-config answers from lib/pkgconfig/python-3.12.pc. When the Python's path holds a character outside ASCII, such as the middle dot of a SubU home directory (/home/Thomas·developer), pkg-config prints that character with a backslash before each of its bytes, which is no longer valid UTF-8, and Meson stops with "'utf-8' codec can't decode byte 0xc2 ... invalid continuation byte". With the .pc files out of sight, Meson asks the Python interpreter itself, and its answer is correct. The .pc files serve only C programs that build against Python, so moving them aside for the install affects nothing else. On a path of plain ASCII the move is unnecessary but harmless.
+
+If a build stops with "Dependency ... found: NO", the development files of that library are missing; install its -devel package and run pip again.
+
+** 2.3 Check
+
+#+begin_src bash
+python3 -c 'import gi; gi.require_version("Gtk" ,"4.0"); from gi.repository import Gtk; print("GTK" ,Gtk.get_major_version())'
+#+end_src
+
+* 3. The vanilla project Python
+
+This is the Harmony recipe for a project Python, unchanged. It is written for Python 3.12.3, though nothing in the project depends on version-specific features.
+
+The build can be moved. Nothing it depends on records the absolute directory it was built in, so the directory can move with a home directory, or to another machine with compatible system libraries, and keep working.
+
+** 3.1 Install the development libraries
+
+Python's configure step looks for the development files of each optional library, and silently leaves out any module whose library it does not find. A build without sqlite3-devel, for example, has no sqlite3 module, and the gap shows only when a program imports it. Install these first.
+
+#+begin_src bash
+sudo zypper install gcc make sqlite3-devel libopenssl-devel libffi-devel zlib-devel \
+  readline-devel ncurses-devel xz-devel libbz2-devel gdbm-devel tk-devel libuuid-devel
+#+end_src
+
+zlib-ng-compat-devel serves in place of zlib-devel, and the two conflict; if zypper reports that conflict, keep the one already installed.
+
+** 3.2 Download and extract
+
+Download the Python source code and extract it as a sibling of the project directory. From the top of the project:
 
 #+begin_src bash
 cd ../
@@ -13,26 +88,41 @@ tar -xzf Python-3.12.3.tgz
 cd Python-3.12.3
 #+end_src
 
-* 2. Build the Environment
-Compile the Python binaries in place. Setting the installation prefix to the current directory isolates the build, and enabling shared libraries ensures compatibility with C extensions.
+** 3.3 Build
+
+Compile in place. Setting the installation prefix to the current directory isolates the build.
 
 #+begin_src bash
-./configure --prefix="$(pwd)" --enable-optimizations --enable-shared
-make -j8
+CONFIG_SITE= ./configure --prefix="$(pwd)" --libdir="$(pwd)/lib" --enable-optimizations
+make -j8 2>&1 | tee build.log
 make install
 #+end_src
 
-* 3. Link the Project
+Everything installs inside this directory; nothing is written to the system.
 
-Python-3.12.3 is linked to a Harmony derived project by default.  
+CONFIG_SITE= and --libdir keep the machine's own defaults out of the build, so the recipe gives the same layout on every Linux. Some distributions, openSUSE and Fedora among them, name a site file in CONFIG_SITE that sets the library directory to lib64 for every configure script. Python then installs its compiled modules under lib64 while the interpreter looks for them under lib, and every import of a compiled module, such as ssl, fails.
 
-See administrator/document/how-to_add-project-dependency.html for details about linking a project, if you want to check the setup.
+The build is static: the interpreter is one program, with no separate libpython3.12.so to find when it starts. A build with --enable-shared records the absolute path of its library directory in the program, and stops working when the directory moves. C extension modules, such as those pip installs, work with the static build.
 
-If you are installing a different version, then at least the Python-3.12.3 version project linking is there as a good example.
+Near the end of build.log, make lists any optional modules it could not build, under "The necessary bits to build these optional modules were not found". nis, an obsolete network directory service, and _curses_panel may appear there; the project uses neither. For any other module listed, install the missing development library and build again.
 
-Project linking is not difficult, make a sym link to `project/<dependency>`, and then add an init file, as described in administrator/document/how-to_add-project-dependency.html. The 'project' in the link is literal.
+** 3.4 Check the build
 
+#+begin_src bash
+bin/python3 -c 'import sys ,ssl ,sqlite3 ,ctypes ,lzma ,bz2 ,readline ,tkinter; print(sys.prefix)'
+ldd bin/python3 | grep libpython        # prints nothing: no shared libpython
+#+end_src
+
+** 3.5 Use pip through the interpreter
 
+The scripts make install writes into bin/, such as pip3, start with a #! line naming the absolute path of the interpreter, so they break when the directory moves. Run pip as a module instead, which uses whichever interpreter runs it:
+
+#+begin_src bash
+python3 -m pip install <package>
+#+end_src
 
+** 3.6 Link the project
 
+The project reaches the build through shared/project-dependency/Python-3.12.3, a link to project/Python-3.12.3, where project is the link to the directory holding this project and its siblings. A build extracted as a sibling of the project, as in 3.2, is found without further steps.
 
+See administrator/document/how-to_add-project-dependency.html for how dependencies are linked, or to link a different version.
index a7a66aa..5ecb239 100644 (file)
@@ -74,8 +74,17 @@ umask 0077
   PATH="$REPO_HOME/shared/authored:$PATH"
 
   # Remove duplicates, keeping the first occurrence
-  clean_path() {
-    PATH=$(echo ":$PATH" | awk -v RS=: -v ORS=: '!seen[$0]++' | sed 's/^://; s/:$//')
+  clean_path(){
+    local entry result=""
+    local -A seen=()
+    local -a part
+    IFS=: read -r -a part <<< "$PATH"
+    for entry in "${part[@]}"; do
+      [[ -z "$entry" || -n "${seen[$entry]:-}" ]] && continue
+      seen[$entry]=1
+      result="${result:+$result:}$entry"
+    done
+    PATH="$result"
   }
   clean_path