From: Thomas Walker Lynch Date: Fri, 23 Oct 2026 11:49:17 +0000 (+0000) Subject: . X-Git-Url: https://git.reasoningtechnology.com/?a=commitdiff_plain;h=040d4cbda52911aac6061e99cad06a18850d3f18;p=Account-Tree . --- diff --git a/developer/authored/.gitkeep b/developer/authored/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/developer/authored/AccountTree.copy/AccountTree b/developer/authored/AccountTree.copy/AccountTree new file mode 100755 index 0000000..0d7ec6f --- /dev/null +++ b/developer/authored/AccountTree.copy/AccountTree @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- +""" +AccountTree + +The program a person uses to manage the child accounts and other resources +of his login account. AccountTree keeps light containers made of Linux +accounts: below a login account are its child accounts, each a separate +account with its own files and processes, entered through its parent rather +than through the system's login programs. + +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 account that started it. + +Usage: AccountTree [argument ...] + help | -h | --help print this message (also printed when given no arguments) + window [] open the resource window for , default your own + list [] print the child accounts of , default your own + 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_tree +import resource_kind + + +def print_help(): + print(__doc__.strip()) + return 0 + +def account_argument(rest ,command): + if len(rest) > 1: + print(f"Error: {command} is given at most one account name." ,file=sys.stderr) + return None + if not rest: + return account_tree.acting_account() + entry = account_tree.account(rest[0]) + if entry is None: + print(f"Error: there is no system account named '{rest[0]}'." ,file=sys.stderr) + return entry + +def command_window(rest): + entry = account_argument(rest ,"window") + if entry is None: return 1 + import window + return window.run(entry ,account_tree.acting_account()) + +def command_list(rest): + entry = account_argument(rest ,"list") + if entry is None: return 1 + for child in account_tree.child_account_list(entry.name): + print(f"{child.name:<28}{child.uid:>7} {child.home}") + return 0 + +def command_resource(rest): + if rest: + print("Error: resource is given 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__": + try: + sys.exit(CLI()) + except BrokenPipeError: + # the reader of our output, such as head, closed the pipe early + os.dup2(os.open(os.devnull ,os.O_WRONLY) ,sys.stdout.fileno()) + sys.exit(1) diff --git a/developer/authored/AccountTree.copy/AccountTree_admin b/developer/authored/AccountTree.copy/AccountTree_admin new file mode 100755 index 0000000..fb094c9 --- /dev/null +++ b/developer/authored/AccountTree.copy/AccountTree_admin @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- +""" +AccountTree_admin + +The administrator's program. It runs as root, and calls the work functions +directly, so it works whether or not the AccountTree server is running. A +person manages the child accounts of his own login account with the +AccountTree program. + +AccountTree keeps light containers made of Linux accounts. The accounts form +a tree: root at the top, a login account for each person who logs in, and +below a login account its child accounts, which are entered from their parent +rather than through the system's login programs. + +There are two kinds of database: + system database /var/lib/AccountTree/system.sqlite3, this system's own + record, identified by its system uuid. Its system map is + the authority for which storage accounts are the same + accounts as system accounts here. + storage database /database.sqlite3, the storage accounts 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 tree dir is /home/AccountTree and holds the +storage database and account_data, the storage accounts' homes. On the disk +the system runs from, the tree dir is /home/AccountTree; on a drive mounted at +/mnt/Eagle it is /mnt/Eagle/home/AccountTree. + +An account specifier names an account in one place: + system a system account on this system + storage a storage account of the storage + database in that tree dir + +Keywords, such as make-system-account, are words given after a command's +other arguments, in any order, as the command itself is a word. + +Usage: AccountTree_admin [argument ...] + AccountTree_admin preview [argument ...] + help | -h | --help print this message (also printed when given no arguments) + + system make [ ] + make the system database, with a new system uuid and + the range of system uids AccountTree gives out + (default 2000 to 59999) + system show print the system database, and verify it + system delete delete the system database; refused while any system + account is the same account as a storage account + + storage make make the tree dir with an empty storage database and + account_data + storage show print the storage database, and verify it + storage delete [delete-account-data] [forget-other-systems] + undo on this system what AccountTree did for the + storage database's accounts, then delete the tree + dir. Every check is made first, and a failed check + changes nothing. It deletes the system accounts + AccountTree made and keeps adopted ones. Homes + holding files are deleted only with + delete-account-data + + account make [uid ] [parent ] [make-system-account] + make a storage account, with its home in account_data; + its database uid is the next counting down from + 1999999999, or uid keeps an existing uid. Its parent is + the account its name begins with, before the last '·'. + make-system-account also makes a system account of the + same name, the same account as it + + is-same-account [make-system-account] + record in the system map that a system account and a + storage account are the same account. The system + account is adopted when it exists; make-system-account + makes it, with a new system uid, when it does not + is-not-same-account + undo is-same-account: unmount the home, delete a + system account AccountTree made, keep an adopted one, + retire the system uid + same-account show [storage ] + print which accounts are the same account here + same-account suggest storage + print is-same-account commands proposed by matching + names, to read and run; changes nothing + + attach [branch] mount the home of each storage account the system map + names at /home/, nosuid and nodev. A + storage that went back to an older copy, or sideways + to a branch, is attached with a warning. branch first + makes the storage a branch of its own, with system + accounts named ~<8 hex digits>, so it + and the line it came from can both be attached + detach unmount them; refused for a system account with a + login session + + limit set + set how many child accounts a system account may have + below it; one with no limit set may have none + + preview print the log of what would do, change nothing + +The order on a new system: system make, storage make, account make, 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_account +from database import DatabaseError + +# first words that take a second word to make the command +OBJECT = ("system" ,"storage" ,"account" ,"same-account" ,"limit") + +# commands that only read, and so may be run by anyone with read access +READ_ONLY = ("system show" ,"storage show" ,"same-account show" ,"same-account suggest") + + +def print_help(): + print(__doc__.strip()) + return 0 + +def split_keyword(rest ,positional_count ,word_set=() ,value_word_set=()): + """ + The first positional_count arguments are positional. Each argument after + them is a keyword: a word from word_set, standing alone, or a word from + value_word_set, followed by its value. Like the command itself, a keyword is + a word and not a flag. Returns (positional ,keyword) where keyword maps each + word given to True or to its value. + """ + if len(rest) < positional_count: + raise ValueError(f"{positional_count} arguments are needed before any keyword") + positional ,tail ,keyword = rest[:positional_count] ,rest[positional_count:] ,{} + i = 0 + while i < len(tail): + word = tail[i] + if word in word_set: + keyword[word] = True; i += 1 + elif word in value_word_set: + if i + 1 >= len(tail): + raise ValueError(f"'{word}' needs a value after it") + keyword[word] = tail[i + 1]; i += 2 + else: + known = sorted(set(word_set) | set(value_word_set)) + raise ValueError(f"'{word}' is not a keyword here" + (f"; the keywords are {', '.join(known)}" if known else "")) + return positional ,keyword + +def integer(text ,what): + try: + return int(text) + except ValueError: + raise ValueError(f"{what} must be a whole number, not '{text}'") + +def account_specifier_list(rest): + """ + Parse account specifiers from rest: 'system ' + and 'storage '. + """ + result = [] + i = 0 + while i < len(rest): + kind = rest[i] + if kind not in ("system" ,"storage") or i + 2 >= len(rest): + raise ValueError("an account specifier is 'system ' or" + f" 'storage ', not '{' '.join(rest[i:])}'") + if kind == "system": + result.append({"kind": "system" ,"host": rest[i + 1] ,"account": rest[i + 2]}) + else: + result.append({"kind": "storage" ,"tree_dir": rest[i + 1] ,"account": rest[i + 2]}) + i += 3 + return result + +def system_and_storage(specifier_list): + """ + In 5.0, is-same-account relates a system account and a storage account, + 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 account specifiers are one system account and one storage account") + 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: AccountTree_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-account show" and len(rest) in (0 ,2): + if rest and rest[0] != "storage": return usage_error(command) + return same_account.same_account_show({"tree_dir": rest[1]} if rest else None) + if command == "same-account suggest" and len(rest) == 2 and rest[0] == "storage": + return same_account.same_account_suggest({"tree_dir": rest[1]}) + + # ---- changes + if command == "system make" and len(rest) in (0 ,2): + if rest: + first ,last = integer(rest[0] ,"") ,integer(rest[1] ,"") + 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: + (tree_dir,) ,keyword = split_keyword(rest ,1 ,("delete-account-data" ,"forget-other-systems")) + header(command ,tree_dir ,preview) + storage.storage_delete(tree_dir ,"delete-account-data" in keyword ,"forget-other-systems" in keyword ,log ,preview) + elif command == "account make" and len(rest) >= 2: + (tree_dir ,name) ,keyword = split_keyword(rest ,2 ,("make-system-account",) ,("uid" ,"parent")) + database_uid = integer(keyword["uid"] ,"uid") if "uid" in keyword else None + header(command ,tree_dir ,preview) + storage.account_make(tree_dir ,name ,database_uid ,keyword.get("parent") ,"make-system-account" in keyword ,log ,preview) + elif command == "is-same-account" and rest: + specifier ,keyword = split_keyword(rest ,6 ,("make-system-account",)) + system_spec ,storage_spec = system_and_storage(account_specifier_list(specifier)) + header(command ,None ,preview) + same_account.is_same_account(system_spec ,storage_spec ,"make-system-account" in keyword ,log ,preview) + elif command == "is-not-same-account" and rest: + specifier ,_ = split_keyword(rest ,6) + system_spec ,storage_spec = system_and_storage(account_specifier_list(specifier)) + header(command ,None ,preview) + same_account.is_not_same_account(system_spec ,storage_spec ,log ,preview) + elif command == "attach" and rest: + (tree_dir,) ,keyword = split_keyword(rest ,1 ,("branch",)) + header(command ,tree_dir ,preview) + storage.attach(tree_dir ,"branch" in keyword ,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] ,"") ,log ,preview) + else: + return usage_error(command) + except (DatabaseError ,ValueError) as error: + log.error(command ,str(error)) + return log.summary() + +def header(command ,tree_dir ,preview): + where = f" on {os.path.abspath(tree_dir)}" if tree_dir else "" + print(f"AccountTree_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 'AccountTree_admin help' for the commands." ,file=sys.stderr) + return 1 + + +if __name__ == "__main__": + try: + sys.exit(CLI()) + except BrokenPipeError: + # the reader of our output, such as head, closed the pipe early + os.dup2(os.open(os.devnull ,os.O_WRONLY) ,sys.stdout.fileno()) + sys.exit(1) diff --git a/developer/authored/AccountTree.copy/SubU b/developer/authored/AccountTree.copy/SubU deleted file mode 100755 index 5fdb41e..0000000 --- a/developer/authored/AccountTree.copy/SubU +++ /dev/null @@ -1,85 +0,0 @@ -#!/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 [argument ...] - help | -h | --help print this message (also printed when given no arguments) - window [] open the resource window for , default yourself - list [] print the SubUs of , 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 deleted file mode 100755 index 1623fc7..0000000 --- a/developer/authored/AccountTree.copy/SubU_admin +++ /dev/null @@ -1,248 +0,0 @@ -#!/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 /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 /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 an account on this system - storage a user of the storage database in that SubU dir - -Usage: SubU_admin [argument ...] - SubU_admin preview [argument ...] - help | -h | --help print this message (also printed when given no arguments) - - system make [ ] - 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 make the SubU dir with an empty storage database and - user_data - storage show print the storage database, and verify it - storage delete [--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 [--uid ] [--masu ] - 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 [--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 - undo is-same-user: unmount the home, delete an account - SubU made, keep an adopted one, retire the system uid - same-user show [storage ] - print who is the same user as whom on this system - same-user suggest storage - print is-same-user commands proposed by matching names, - to read and run; changes nothing - - attach mount the home of each storage user the system map - names at /home/, nosuid and nodev - detach unmount them; refused for an account that is logged in - - limit set set how many SubUs an account may have below it; - an account with no limit set may have none - - preview print the log of what 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 ' and - 'storage '. - """ - 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 ' or 'storage ', 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] ,"") ,integer(rest[1] ,"") - 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] ,"") ,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 deleted file mode 100644 index 194738f..0000000 --- a/developer/authored/AccountTree.copy/account.py +++ /dev/null @@ -1,137 +0,0 @@ -# -*- 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 · is a SubU of . 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/account_tree.py b/developer/authored/AccountTree.copy/account_tree.py new file mode 100644 index 0000000..0195929 --- /dev/null +++ b/developer/authored/AccountTree.copy/account_tree.py @@ -0,0 +1,136 @@ +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- +""" +account_tree - the system accounts as a tree, read from their names + +root is at the top of the tree, and a login account is a child of root. Every +account is entered through its parent: root enters a login account through +the system's login programs, and a login account enters its child accounts, +and they theirs, through the AccountTree server. An account's name is its parent's name, +the separator, and its own short name: Thomas·developer·scratch is a child of +Thomas·developer, a child of the login account Thomas, a child of root. + +Until the window reads the system database through the server, the tree is +read from the system accounts' names. 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. While existing +system 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 login account whose own name contains '_' +would be misread as a child account. +""" + +import os +from dataclasses import dataclass +from typing import Optional + +import system_account as sa +import kernel +from kernel import KernelError + +SEPARATOR = "·" +OLD_SEPARATOR = "_" +READ_OLD_SEPARATOR = True +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 from_entry(entry) -> Account: + return Account(entry.pw_name ,entry.pw_uid ,entry.pw_dir) + +def account(name: str) -> Optional[Account]: + entry = sa.system_account(name) + return from_entry(entry) if entry else None + +def acting_account() -> Account: + return from_entry(sa.system_account_by_system_uid(os.getuid())) + +def is_person_account(entry) -> bool: + """ + A login account or a child account: an account of a person, not one of + the system's own services. + """ + return sa.system_uid_min() <= entry.pw_uid != NOBODY_UID + +def split_name(name: str) -> tuple[str ,str]: + """ + (parent ,short name) of an account name. The parent of a name without a + separator is root; root itself has no parent. + """ + if name == "root": + return ("" ,"root") + if SEPARATOR in name: + return tuple(name.rsplit(SEPARATOR ,1)) + if READ_OLD_SEPARATOR and OLD_SEPARATOR in name: + return tuple(name.rsplit(OLD_SEPARATOR ,1)) + return ("root" ,name) + +def child_account_list(parent: str) -> list[Account]: + """ + The child accounts of parent. + """ + result = [] + for entry in sa.system_account_list(): + if not is_person_account(entry): + continue + if entry.pw_name != parent and split_name(entry.pw_name)[0] == parent: + result.append(from_entry(entry)) + return result + +def child_account_name(parent: str ,short: str) -> str: + """ + The name for a new child account of parent. A short name may not hold the separator. + """ + if SEPARATOR in short: + raise ValueError(f"a short name may not contain '{SEPARATOR}': {short}") + if parent == "root": + return short + return parent + SEPARATOR + short + +def parent_of(name: str) -> str: + return split_name(name)[0] + +def short_name(name: str) -> str: + return split_name(name)[1] + + +# -------------------------------------------------------------------------------- +# state + +STATE_IN_SESSION = "in session" +STATE_IDLE = "idle" +STATE_OFFLINE = "offline" + +def session_system_uid_set() -> set[int]: + """ + The system uids that have at least one session, as systemd-logind counts + them: a desktop login, a console login, or a machinectl shell. Empty when + the sessions cannot be read. + """ + try: + return kernel.session_system_uid_set() + except KernelError: + return set() + +def state(entry: Account ,session_set: set[int]) -> str: + """ + in session the account has a session + idle no session, and its home is here + offline no session, and its home is not here: it is kept on a storage + that is not attached + """ + if entry.uid in session_set: + return STATE_IN_SESSION + if not entry.home_exists: + return STATE_OFFLINE + return STATE_IDLE diff --git a/developer/authored/AccountTree.copy/authority.py b/developer/authored/AccountTree.copy/authority.py index 338adb7..dfe30fc 100644 --- a/developer/authored/AccountTree.copy/authority.py +++ b/developer/authored/AccountTree.copy/authority.py @@ -1,9 +1,9 @@ # -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- """ -authority - the one place that answers "may this user do this?" +authority - the one place that answers "may this account 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 +Every change AccountTree makes is first put to the authority as a request: +the acting account, the action, the target account, 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 diff --git a/developer/authored/AccountTree.copy/common.py b/developer/authored/AccountTree.copy/common.py index 4fdd872..60bebfd 100644 --- a/developer/authored/AccountTree.copy/common.py +++ b/developer/authored/AccountTree.copy/common.py @@ -1,31 +1,41 @@ # -*- 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. +common - what the work modules of AccountTree_admin share: the log, carrying +out a change, 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. +change is made: a database transaction is rolled back, and a change to the +system is logged and not carried out. """ -import re ,pwd ,grp ,socket ,subprocess +import socket from pathlib import Path +import kernel +from kernel import KernelError +from system_account import SystemAccountError + HOME = Path("/home") +# a parent account's name and its child account's last name are joined by this SEPARATOR = "·" -RESERVED_NAME = {"SubU"} +# a system account made for a branch of a storage database is named +# +BRANCH_MARK = "~" +TREE_DIR_NAME = "AccountTree" +RESERVED_NAME = {TREE_DIR_NAME} class Log: - TAG_WIDTH = 22 + TAG_WIDTH = 24 # 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", + "unmounted": "would unmount" ,"adopted": "would adopt", + "made system account": "would make system account" ,"set": "would set" ,"deleted": "would delete", + "deleted system account": "would delete system account" ,"deleted system group": "would delete system group", + "retired": "would retire" ,"mapped": "would map" ,"branched": "would branch", } def __init__(self ,preview): @@ -57,99 +67,46 @@ class Log: return 1 if self.error_count else 0 -def run(command ,log ,preview): +def act(log ,preview ,subject ,function ,*argument): """ - Run a command that changes the system; in a preview, only show it. + Carry out a change to the system, function(*argument); in a preview, do + nothing. Returns True when it was done, or would be; logs the error and + returns False when it failed. """ 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}") + try: + function(*argument) + return True + except (KernelError ,SystemAccountError ,OSError) as error: + log.error(subject ,str(error)) 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(): +def valid_name(name): """ - 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. + Why name cannot be an account name, or None when it can. """ - 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" + return "an account 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 '·'" + return "an account 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" + return "an account name may not contain an empty part between two '·'" if name in RESERVED_NAME: - return f"'{name}' is reserved: /home/{name} holds SubU's own files" + return f"'{name}' is reserved: /home/{name} holds AccountTree's own files" return None -def subu_dir_problem(subu_dir): +def tree_dir_problem(tree_dir): """ - A SubU dir is /home/SubU; every storage keeps the same layout. + Why tree_dir is not a tree dir, or None. A tree dir is + /home/AccountTree; 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 /home/SubU, such as /home/SubU" + path = Path(tree_dir) + if path.name != TREE_DIR_NAME or path.parent.name != "home": + return f"{tree_dir} is not a tree dir; a tree dir is /home/{TREE_DIR_NAME}, such as /home/{TREE_DIR_NAME}" return None def delete_mount_point(target ,log ,preview): @@ -160,14 +117,13 @@ def delete_mount_point(target ,log ,preview): """ if not target.exists(): return - if preview and is_mountpoint(target): + if preview and kernel.is_mount_point(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()): + if kernel.is_mount_point(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() + if not act(log ,preview ,str(target) ,kernel.immutable_set ,target ,False): return + if not act(log ,preview ,str(target) ,target.rmdir): return log.line("deleted" ,str(target) ,"mount point") diff --git a/developer/authored/AccountTree.copy/database.py b/developer/authored/AccountTree.copy/database.py index bceadb7..cbf93ac 100644 --- a/developer/authored/AccountTree.copy/database.py +++ b/developer/authored/AccountTree.copy/database.py @@ -1,11 +1,11 @@ # -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- """ -database - the two kinds of SubU database +database - the two kinds of AccountTree database - storage database /database.sqlite3, identified by its database uuid - The SubU dir is /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 + storage database /database.sqlite3, identified by its database uuid. + The tree dir is /home/AccountTree, so on the + disk a system runs from it is /home/AccountTree. + system database /var/lib/AccountTree/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 @@ -26,7 +26,15 @@ from pathlib import Path STANDARD = "5.0" STANDARD_FOLLOWED = {"5.0"} -GENESIS_HASH = "0" * 64 + + +def genesis_hash(kind ,uuid_text) -> str: + """ + The hash the first transaction follows. It is 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. + """ + return hashlib.sha256(f"AccountTree {kind} {uuid_text}".encode("utf-8")).hexdigest() HERE = Path(__file__).resolve().parent @@ -36,18 +44,18 @@ KIND = { "system": (HERE / "schema_system.sql" ,"system_uuid"), } -SYSTEM_DIR = Path("/var/lib/SubU") +SYSTEM_DIR = Path("/var/lib/AccountTree") SYSTEM_DATABASE_PATH = SYSTEM_DIR / "system.sqlite3" def now() -> str: - return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S Z") + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ") -def storage_database_path(subu_dir) -> Path: - return Path(subu_dir) / "database.sqlite3" +def storage_database_path(tree_dir) -> Path: + return Path(tree_dir) / "database.sqlite3" -def user_data_dir(subu_dir) -> Path: - return Path(subu_dir) / "user_data" +def account_data_dir(tree_dir) -> Path: + return Path(tree_dir) / "account_data" def transaction_hash(previous_hash ,series ,description ,change_text ,nonce) -> str: text = "\n".join([previous_hash ,str(series) ,description ,change_text ,nonce]) @@ -113,10 +121,11 @@ class Database: connection.row_factory = sqlite3.Row database = cls(connection ,path ,kind) connection.executescript(database.schema_path.read_text(encoding="utf-8")) + uuid_text = str(uuid4()) connection.execute("BEGIN") connection.execute( - f"INSERT INTO identity({database.uuid_column} ,series ,head_hash ,created) VALUES (? ,0 ,? ,?)", - (str(uuid4()) ,GENESIS_HASH ,now())) + f"INSERT INTO identity({database.uuid_column} ,genesis_uuid ,series ,head_hash ,created) VALUES (? ,? ,0 ,? ,?)", + (uuid_text ,uuid_text ,genesis_hash(kind ,uuid_text) ,now())) connection.execute("COMMIT") with database.transaction(f"make {kind} database") as t: t.execute("INSERT INTO standard(version) VALUES (?)" ,(STANDARD,)) @@ -149,6 +158,23 @@ class Database: """ return self.uuid()[:8] + def head(self): + """ + (series ,head hash) of the last transaction. + """ + d = self.identity() + return d["series"] ,d["head_hash"] + + def has_head(self ,series ,head_hash) -> bool: + """ + Whether this database's record holds the transaction with that series + number and hash: whether this database is, or descends from, the one that + had that head. + """ + if series == 0: + return head_hash == genesis_hash(self.kind ,self.identity()["genesis_uuid"]) + return self.row("SELECT 1 FROM transaction_log WHERE series = ? AND hash = ?" ,(series ,head_hash)) is not None + def last_change(self): return self.connection.execute("SELECT MAX(time) AS t FROM transaction_log").fetchone()["t"] @@ -201,7 +227,7 @@ class Database: """ problem = [] identity = self.identity() - previous = GENESIS_HASH + previous = genesis_hash(self.kind ,identity["genesis_uuid"]) expected_series = 1 log = self.rows("SELECT * FROM transaction_log ORDER BY series") for entry in log: @@ -230,8 +256,9 @@ class Database: 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"])) + genesis_uuid = identity["genesis_uuid"] + replay.execute(f"INSERT INTO identity({self.uuid_column} ,genesis_uuid ,series ,head_hash ,created) VALUES (? ,? ,0 ,? ,?)", + (genesis_uuid ,genesis_uuid ,genesis_hash(self.kind ,genesis_uuid) ,identity["created"])) try: for entry in log: for statement ,parameter in json.loads(entry["change"]): @@ -247,7 +274,9 @@ class Database: 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" + # every column of identity but series and head_hash, which the chain sets + column_list = [c[1] for c in replay.execute("PRAGMA table_info(identity)") if c[1] not in ("series" ,"head_hash")] + column = "SELECT " + " ,".join(column_list) + " 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/kernel.py b/developer/authored/AccountTree.copy/kernel.py new file mode 100644 index 0000000..6dc6a22 --- /dev/null +++ b/developer/authored/AccountTree.copy/kernel.py @@ -0,0 +1,181 @@ +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- +""" +kernel - direct calls to the Linux kernel and to libsystemd, made through +ctypes, for the work that AccountTree does on mounts, files and sessions. + + home_mount mount an account's home: a bind mount, idmapped when the + database uid and system uid differ, nosuid and nodev + home_unmount unmount it + mount_point_list every mount point, from /proc/self/mountinfo + is_mount_point whether a path is one + immutable_set set or clear a directory's immutable flag + session_system_uid_set + the system uids that have a login session + +home_mount uses the mount calls Linux has had since 5.12: open_tree clones the +source as a detached mount, mount_setattr gives it its attributes and its +idmap, and move_mount places it. An idmap is described to the kernel 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 mount_setattr. +""" + +import os ,re ,ctypes ,ctypes.util ,fcntl ,struct +from pathlib import Path + +LIBC = ctypes.CDLL(None ,use_errno=True) + +# system call numbers; these newer calls have the same numbers on every +# architecture Linux supports, x86_64 and aarch64 among them +SYS_open_tree = 428 +SYS_move_mount = 429 +SYS_mount_setattr = 442 + +AT_FDCWD = -100 +AT_EMPTY_PATH = 0x1000 +OPEN_TREE_CLONE = 1 +OPEN_TREE_CLOEXEC = os.O_CLOEXEC +MOVE_MOUNT_F_EMPTY_PATH = 0x00000004 +MOUNT_ATTR_NOSUID = 0x00000002 +MOUNT_ATTR_NODEV = 0x00000004 +MOUNT_ATTR_IDMAP = 0x00100000 +MNT_DETACH = 0x00000002 + +FS_IOC_GETFLAGS = 0x80086601 +FS_IOC_SETFLAGS = 0x40086602 +FS_IMMUTABLE_FL = 0x00000010 + + +class KernelError(Exception): + pass + + +class MountAttr(ctypes.Structure): + _fields_ = [("attr_set" ,ctypes.c_uint64) ,("attr_clr" ,ctypes.c_uint64), + ("propagation" ,ctypes.c_uint64) ,("userns_fd" ,ctypes.c_uint64)] + + +def check(result ,what): + if result < 0: + number = ctypes.get_errno() + raise KernelError(f"{what}: {os.strerror(number)}") + return result + + +# -------------------------------------------------------------------------------- +# mounts + +def idmap_namespace_fd(database_uid ,system_uid ,system_gid): + """ + A file descriptor for a user namespace whose maps say: a file owned on disk + by database_uid appears as system_uid, and its group database_uid appears as + system_gid. A uid_map line is ' '. For an + idmapped mount the kernel takes the id on disk as an id inside the + namespace, and the id it maps to outside as the id seen through the mount. + """ + ready_read ,ready_write = os.pipe() + release_read ,release_write = os.pipe() + pid = os.fork() + if pid == 0: + try: + os.close(ready_read); os.close(release_write) + os.unshare(os.CLONE_NEWUSER) + os.write(ready_write ,b"x") + os.read(release_read ,1) + finally: + os._exit(0) + os.close(ready_write); os.close(release_read) + try: + if os.read(ready_read ,1) != b"x": + raise KernelError("the child process for the idmap did not start") + Path(f"/proc/{pid}/uid_map").write_text(f"{database_uid} {system_uid} 1\n") + Path(f"/proc/{pid}/gid_map").write_text(f"{database_uid} {system_gid} 1\n") + return os.open(f"/proc/{pid}/ns/user" ,os.O_RDONLY | os.O_CLOEXEC) + finally: + os.write(release_write ,b"x") + os.close(release_write); os.close(ready_read) + os.waitpid(pid ,0) + +def home_mount(source ,target ,database_uid ,system_uid ,system_gid): + """ + Mount source at target, nosuid and nodev, translating database_uid to + system_uid and system_gid unless they are the same. + """ + tree_fd = check(LIBC.syscall(SYS_open_tree ,AT_FDCWD ,os.fsencode(str(source)), + OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC) ,f"open_tree {source}") + userns_fd = None + try: + attr = MountAttr() + attr.attr_set = MOUNT_ATTR_NOSUID | MOUNT_ATTR_NODEV + if not (database_uid == system_uid == system_gid): + userns_fd = idmap_namespace_fd(database_uid ,system_uid ,system_gid) + attr.attr_set |= MOUNT_ATTR_IDMAP + attr.userns_fd = userns_fd + check(LIBC.syscall(SYS_mount_setattr ,tree_fd ,b"" ,AT_EMPTY_PATH ,ctypes.byref(attr), + ctypes.sizeof(attr)) ,f"mount_setattr {source}") + check(LIBC.syscall(SYS_move_mount ,tree_fd ,b"" ,AT_FDCWD ,os.fsencode(str(target)), + MOVE_MOUNT_F_EMPTY_PATH) ,f"move_mount {target}") + finally: + os.close(tree_fd) + if userns_fd is not None: os.close(userns_fd) + +def home_unmount(target): + check(LIBC.umount2(os.fsencode(str(target)) ,0) ,f"umount2 {target}") + +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 is_mount_point(path): + return os.path.abspath(str(path)) in set(mount_point_list()) + + +# -------------------------------------------------------------------------------- +# files + +def immutable_set(path ,on): + """ + Set or clear the immutable flag of a file or directory, as chattr +i and + chattr -i do: an immutable file cannot be changed, renamed or deleted, even + by root, until the flag is cleared. + """ + fd = os.open(str(path) ,os.O_RDONLY | os.O_NONBLOCK | os.O_CLOEXEC) + try: + buffer = bytearray(struct.pack("i" ,0)) + fcntl.ioctl(fd ,FS_IOC_GETFLAGS ,buffer) + flag = struct.unpack("i" ,buffer)[0] + flag = (flag | FS_IMMUTABLE_FL) if on else (flag & ~FS_IMMUTABLE_FL) + fcntl.ioctl(fd ,FS_IOC_SETFLAGS ,struct.pack("i" ,flag)) + finally: + os.close(fd) + + +# -------------------------------------------------------------------------------- +# sessions + +def session_system_uid_set(): + """ + The system uids that have a login session, from systemd-logind through + sd_get_uids in libsystemd. Empty where logind is not running. + """ + name = ctypes.util.find_library("systemd") or "libsystemd.so.0" + try: + libsystemd = ctypes.CDLL(name) + except OSError: + raise KernelError("libsystemd is not available, so login sessions cannot be read") + pointer = ctypes.POINTER(ctypes.c_uint32)() + count = libsystemd.sd_get_uids(ctypes.byref(pointer)) + if count < 0: + return set() + try: + return {pointer[i] for i in range(count)} + finally: + LIBC.free(pointer) diff --git a/developer/authored/AccountTree.copy/resource_kind.py b/developer/authored/AccountTree.copy/resource_kind.py index a0a9abb..cb1ca11 100644 --- a/developer/authored/AccountTree.copy/resource_kind.py +++ b/developer/authored/AccountTree.copy/resource_kind.py @@ -1,6 +1,6 @@ # -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- """ -resource_kind - the kinds of resource a user can be allocated +resource_kind - the kinds of resource an account 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 @@ -36,29 +36,29 @@ GROUP_TITLE = { } 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("child account" ,"account", + "a new account below this one; it opens its own resource window"), + ResourceKind("capture account" ,"account", + "take an existing system account as a child account of this one; the authority decides whether this is allowed"), ResourceKind("home directory" ,"storage", - "the user's own home, kept on a device and mounted at /home/"), + "the account's own home, kept on a storage and mounted at /home/"), 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"), + "an idmapped mount that shows a child account's files as owned by this account"), + ResourceKind("storage" ,"storage", + "a persistent storage device, removable or encrypted, this account may open and attach"), ResourceKind("network zone" ,"network", - "a network namespace of the user's own; all of the user's traffic goes through it"), + "a network namespace of the account's own; all of the account'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"), + "permission to open windows on the parent account's display"), ResourceKind("sound card" ,"device", - "the lease on the sound card; one user holds it at a time (subu_sound does this today)"), + "the lease on the sound card; one account holds it at a time (the old subu_sound script does this today)"), ResourceKind("graphics chip" ,"device", "use of the GPU, or of a virtual slice of it"), ResourceKind("USB device" ,"device", diff --git a/developer/authored/AccountTree.copy/same_account.py b/developer/authored/AccountTree.copy/same_account.py new file mode 100644 index 0000000..55c34d6 --- /dev/null +++ b/developer/authored/AccountTree.copy/same_account.py @@ -0,0 +1,283 @@ +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- +""" +same_account - the work functions that say which accounts are the same account + +An account specifier names an account in one place: + + system a system account on this system + storage a storage account of the storage + database in that tree dir + +is-same-account is given two account specifiers. In 5.0 one names a system +account and the other a storage account; the correspondence is written into +the system map of the system database, which is the authority for which +storage accounts have system accounts here. The storage database records +only that this system has seen it. +""" + +import os + +import database +from database import Database +import kernel +from common import HOME ,act ,host_name ,valid_name ,tree_dir_problem ,delete_mount_point +from system import open_system_database +import system_account as sa + + +# -------------------------------------------------------------------------------- +# opening the two sides + +def open_sides(system_spec ,storage_spec ,log): + """ + Check the two account 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 system accounts can be named") + return None + system_db = open_system_database(log) + if system_db is None: return None + problem = tree_dir_problem(storage_spec["tree_dir"]) + if problem: + log.error(storage_spec["tree_dir"] ,problem); return None + storage_db = Database.open(database.storage_database_path(storage_spec["tree_dir"]) ,"storage") + return system_db ,storage_db + +def mount_target(row): + """ + Where the home of the system account in a system map row is mounted: + /home/, which the system account's home in /etc/passwd must be. + """ + return HOME / row["system_account_name"] ,sa.system_account_by_system_uid(row["system_uid"]) + +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 kernel.is_mount_point(target) and (a.st_dev ,a.st_ino) == (b.st_dev ,b.st_ino) + +def head_seen_record(system_db ,storage_db ,event ,continuity ,preview): + """ + Record in the system database the storage database's head now. + """ + series ,head_hash = storage_db.head() + with system_db.transaction(f"{event} database {storage_db.short()} at series {series}" ,preview) as t: + t.execute("INSERT INTO storage_head_seen(database_uuid ,series ,head_hash ,event ,continuity ,seen)" + " VALUES (? ,? ,? ,? ,? ,?)" ,(storage_db.uuid() ,series ,head_hash ,event ,continuity ,database.now())) + return series + +def system_seen_record(storage_db ,system_uuid ,log ,preview): + """ + Record in the storage database that this system has seen it now. + """ + stamp = database.now() + if storage_db.row("SELECT 1 FROM system_seen WHERE system_uuid = ?" ,(system_uuid,)): + with storage_db.transaction(f"seen by system {system_uuid[:8]}" ,preview) as t: + t.execute("UPDATE system_seen SET last = ? WHERE system_uuid = ?" ,(stamp ,system_uuid)) + else: + with storage_db.transaction(f"first seen by system {system_uuid[:8]}" ,preview) as t: + t.execute("INSERT INTO system_seen(system_uuid ,first ,last) VALUES (? ,? ,?)" ,(system_uuid ,stamp ,stamp)) + log.line("recorded" ,f"system {system_uuid[:8]}" ,"in the storage database's systems seen") + + +# -------------------------------------------------------------------------------- +# is-same-account + +def is_same_account(system_spec ,storage_spec ,make_system_account ,log ,preview): + opened = open_sides(system_spec ,storage_spec ,log) + if opened is None: return + system_db ,storage_db = opened + same_account_map(system_db ,storage_db ,system_spec["account"] ,storage_spec["account"] ,make_system_account ,log ,preview) + +def same_account_map(system_db ,storage_db ,system_account_name ,storage_account_name ,make_system_account ,log ,preview): + """ + Record in the system map that a system account and a storage account are the + same account, adopting the system account when it exists, or making it when + make_system_account is given. Returns True when the row was made, or would be. + """ + storage_account = storage_db.row("SELECT * FROM account WHERE name = ?" ,(storage_account_name,)) + if storage_account is None: + log.error(storage_account_name ,f"no such storage account in database {storage_db.short()}"); return False + if storage_account["state"] != "active": + log.error(storage_account_name ,f"the storage account is {storage_account['state']}"); return False + 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 ,storage_account["database_uid"])) + if existing: + if existing["system_account_name"] == system_account_name: + log.line("kept" ,f"{system_account_name} is {storage_account_name}" ,"already the same account"); return True + log.error(storage_account_name ,f"already the same account as system account {existing['system_account_name']}"); return False + + entry = sa.system_account(system_account_name) + if entry is not None: + if make_system_account: + log.error(system_account_name ,"the system account exists; make-system-account is for one that does not"); return False + if entry.pw_uid < sa.system_uid_min(): + log.error(system_account_name ,f"system uid {entry.pw_uid} is below UID_MIN {sa.system_uid_min()}, an account of the system's own services"); return False + taken = system_db.row("SELECT * FROM system_map WHERE system_uid = ? AND state = 'in use'" ,(entry.pw_uid,)) + if taken: + log.error(system_account_name ,f"already the same account as database uid {taken['database_uid']} of database {taken['database_uuid'][:8]}"); return False + if entry.pw_dir != str(HOME / system_account_name): + log.error(system_account_name ,f"its home is {entry.pw_dir}; AccountTree mounts a home only at /home/"); return False + system_uid ,system_gid ,origin = entry.pw_uid ,entry.pw_gid ,"adopted" + log.line("adopted" ,system_account_name ,f"existing system account, system uid {system_uid}") + else: + if not make_system_account: + log.error(system_account_name ,"no such system account; give the keyword make-system-account to make it"); return False + problem = valid_name(system_account_name) + if problem: + log.error(system_account_name ,problem); return False + home = HOME / system_account_name + if kernel.is_mount_point(home) or (home.exists() and (not home.is_dir() or any(home.iterdir()))): + log.error(str(home) ,"already holds something; a system account made here would take it as its home"); return False + system_uid = free_system_uid(system_db) + if system_uid is None: + log.error(system_account_name ,"no free system uid in this system's range"); return False + if not act(log ,preview ,system_account_name ,sa.system_account_make ,system_account_name ,system_uid): return False + system_gid = system_uid if preview else sa.system_account(system_account_name).pw_gid + origin = "made" + log.line("made system account" ,system_account_name ,f"system uid {system_uid}, gid {system_gid}") + + system_seen_record(storage_db ,system_db.uuid() ,log ,preview) + with system_db.transaction(f"{system_account_name} is the same account as {storage_account_name}" + f" of database {storage_db.short()}" ,preview) as t: + t.execute("INSERT INTO system_map(system_uid ,system_gid ,system_account_name ,database_uuid ,database_uid" + " ,origin ,state ,mapped) VALUES (? ,? ,? ,? ,? ,? ,'in use' ,?)", + (system_uid ,system_gid ,system_account_name ,database_uuid ,storage_account["database_uid"] ,origin ,database.now())) + head_seen_record(system_db ,storage_db ,"mapped" ,None ,preview) + log.line("mapped" ,f"{system_account_name} is {storage_account_name}", + f"system uid {system_uid} is database uid {storage_account['database_uid']} of database {storage_db.short()}") + return True + +def free_system_uid(system_db): + """ + The lowest system uid in the range that no system account and no system + 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 sa.system_account_by_system_uid(c) is not None or sa.system_group_by_system_gid(c) is not None: + continue + return c + return None + + +# -------------------------------------------------------------------------------- +# is-not-same-account + +def is_not_same_account(system_spec ,storage_spec ,log ,preview): + opened = open_sides(system_spec ,storage_spec ,log) + if opened is None: return + system_db ,storage_db = opened + system_account_name ,storage_account_name = system_spec["account"] ,storage_spec["account"] + storage_account = storage_db.row("SELECT * FROM account WHERE name = ?" ,(storage_account_name,)) + if storage_account is None: + log.error(storage_account_name ,f"no such storage account 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() ,storage_account["database_uid"])) + if row is None: + log.line("kept" ,storage_account_name ,"not the same account as any system account here; nothing to undo"); return + if row["system_account_name"] != system_account_name: + log.error(storage_account_name ,f"is the same account as {row['system_account_name']}, not {system_account_name}"); return + if row["system_uid"] in kernel.session_system_uid_set(): + log.error(system_account_name ,"has a login session; nothing was changed"); return + same_account_end(row ,system_db ,storage_spec["tree_dir"] ,storage_account_name ,log ,preview) + +def same_account_end(row ,system_db ,tree_dir ,storage_account_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 system account if + AccountTree made it, and retire the row. Returns False when it had to stop. + """ + name = row["system_account_name"] + target ,entry = mount_target(row) + source = database.account_data_dir(tree_dir) / storage_account_name + if is_mounted_from(target ,source): + if not act(log ,preview ,str(target) ,kernel.home_unmount ,target): + log.line("stopped" ,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["origin"] == "made" and entry is not None and entry.pw_name == name: + group_entry = sa.system_group(name) + if not act(log ,preview ,name ,sa.system_account_delete ,name): return False + log.line("deleted system 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 preview or sa.system_group(name) is not None: + if not act(log ,preview ,name ,sa.system_group_delete ,name): return False + log.line("deleted system group" ,name ,f"gid {row['system_gid']}") + elif row["origin"] == "adopted": + log.line("kept" ,f"system account {name}" ,"it existed before AccountTree adopted it") + with system_db.transaction(f"{name} is no longer the same account 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_account_show(storage_spec): + """ + The system accounts that are the same accounts as storage accounts; given a + storage, its storage accounts that are not, and the login 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["tree_dir"]) ,"storage") + in_use = system_db.rows("SELECT * FROM system_map WHERE state = 'in use' ORDER BY system_account_name") + if storage_db: + in_use = [m for m in in_use if m["database_uuid"] == storage_db.uuid()] + print(f"same accounts on system {host_name()}") + for m in in_use: + other = f"database uid {m['database_uid']}" + if storage_db: + a = storage_db.row("SELECT name FROM account WHERE database_uid = ?" ,(m["database_uid"],)) + other = a["name"] if a else "(no such storage account)" + print(f" system {m['system_account_name']:<26} storage {m['database_uuid'][:8]} {other} ({m['origin']})") + if storage_db: + mapped = {m["database_uid"] for m in in_use} + print(f"storage accounts of database {storage_db.short()} that are not the same account as any system account here") + for a in storage_db.rows("SELECT * FROM account WHERE state = 'active' ORDER BY name"): + if a["database_uid"] not in mapped: + print(f" {a['name']}") + mapped_name = {m["system_account_name"] for m in system_db.rows("SELECT system_account_name FROM system_map WHERE state = 'in use'")} + print("system accounts of persons here that are not the same account as any storage account") + for entry in sa.system_account_list(): + if sa.system_uid_min() <= entry.pw_uid < 60000 and entry.pw_name not in mapped_name: + print(f" {entry.pw_name}") + return 0 + +def same_account_suggest(storage_spec): + """ + Print is-same-account commands for the storage accounts that are not yet the + same account as any system account, proposed by matching names. Changes nothing. + """ + system_db = Database.open(database.SYSTEM_DATABASE_PATH ,"system") + tree_dir = storage_spec["tree_dir"] + storage_db = Database.open(database.storage_database_path(tree_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 a in storage_db.rows("SELECT * FROM account WHERE state = 'active' ORDER BY name"): + if a["database_uid"] in mapped: continue + entry = sa.system_account(a["name"]) + line = f"AccountTree_admin is-same-account system {host_name()} '{a['name']}' storage {tree_dir} '{a['name']}'" + if entry is None: + print(line + " make-system-account") + elif entry.pw_uid in taken or entry.pw_uid < sa.system_uid_min(): + print(f"# {a['name']}: the system account of that name cannot be adopted (system uid {entry.pw_uid})") + else: + print(line + f" # adopts the existing system account, system uid {entry.pw_uid}") + return 0 diff --git a/developer/authored/AccountTree.copy/same_user.py b/developer/authored/AccountTree.copy/same_user.py deleted file mode 100644 index ab85b6e..0000000 --- a/developer/authored/AccountTree.copy/same_user.py +++ /dev/null @@ -1,256 +0,0 @@ -# -*- 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 an account on this system - storage 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/. - """ - 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/"); 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 index 037de86..f9317e8 100644 --- a/developer/authored/AccountTree.copy/schema_storage.sql +++ b/developer/authored/AccountTree.copy/schema_storage.sql @@ -1,13 +1,15 @@ -- schema_storage.sql -- --- The storage database, standard 5.0, at /database.sqlite3, where the --- SubU dir is /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. +-- The storage database, standard 5.0, at /database.sqlite3, where the +-- tree dir is /home/AccountTree. It records the storage accounts +-- kept on the storage. It grants nothing on any system: which storage accounts +-- are the same accounts as system 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 +-- database uid a storage account's uid in this database's own numbering, +-- which counts down from 1999999999; the account'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 @@ -15,27 +17,35 @@ -- 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. +-- genesis_uuid is the database uuid it was made with, from which its chain +-- begins. A database made by branching from another gets a new database uuid, +-- and records the database it branched from and the head they parted after. 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 + database_uuid TEXT NOT NULL, + genesis_uuid TEXT NOT NULL, + branch_of TEXT, -- database uuid this one branched from + branch_after_series INTEGER, + branch_after_head TEXT, + 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. +-- The AccountTree 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 +-- The storage accounts this database carries. A storage account's parent is +-- named by its name, and by its database uid when the parent is carried here. +CREATE TABLE account ( + database_uid INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + parent_database_uid INTEGER, -- NULL when the parent is not carried here + parent_name TEXT, -- NULL for an account whose parent is root + 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. @@ -44,22 +54,26 @@ CREATE TABLE database_uid ( 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 ( +-- The systems that have seen this database: those on which a storage account +-- of it has been made the same account as a system account, with when that +-- was first, and when the database was last attached or detached there. For +-- the owner of the storage, and for storage delete, which will not leave a +-- system mapping a database that no longer exists. Never authority: each +-- system's own system database is the record of which accounts are the same. +CREATE TABLE system_seen ( system_uuid TEXT PRIMARY KEY, - noted TEXT NOT NULL + first TEXT NOT NULL, + last 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. +-- Other databases this one knows, and their storage accounts that are the +-- same accounts as storage accounts here. Not used in 5.0. CREATE TABLE acquaintance_database ( database_uuid TEXT PRIMARY KEY, introduced TEXT NOT NULL ); -CREATE TABLE same_user_database ( +CREATE TABLE same_account_database ( other_database_uuid TEXT NOT NULL, other_database_uid INTEGER NOT NULL, database_uid INTEGER NOT NULL, diff --git a/developer/authored/AccountTree.copy/schema_system.sql b/developer/authored/AccountTree.copy/schema_system.sql index aa82f4c..2dc372b 100644 --- a/developer/authored/AccountTree.copy/schema_system.sql +++ b/developer/authored/AccountTree.copy/schema_system.sql @@ -1,60 +1,78 @@ -- 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. +-- The system database, standard 5.0, at /var/lib/AccountTree/system.sqlite3. +-- It is the system's own record, and the authority for which storage accounts +-- are the same accounts as system 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 +-- system uid a system account's uid, in /etc/passwd -- database uuid names a storage database --- database uid a user's uid in that storage database's own numbering +-- database uid a storage account's uid in that 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. +-- genesis_uuid is the system uuid, from which the chain begins. CREATE TABLE identity ( - system_uuid TEXT NOT NULL, - series INTEGER NOT NULL, - head_hash TEXT NOT NULL, - created TEXT NOT NULL + system_uuid TEXT NOT NULL, + genesis_uuid TEXT NOT NULL, + series INTEGER NOT NULL, + head_hash TEXT NOT NULL, + created TEXT NOT NULL ); --- The SubU standards this database follows. +-- The AccountTree 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. +-- The range of system uids AccountTree gives the system accounts it makes. 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. +-- The system map: each system account that is the same account as a storage +-- account. A row is never deleted; when the two stop being the same account it +-- is retired. A system uid in any row, in use or retired, is never given to a +-- system account AccountTree makes. A system account AccountTree adopted, 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, + system_account_name TEXT NOT NULL, -- kept so that a system account lost from /etc/passwd can be made again + database_uuid TEXT NOT NULL, + database_uid INTEGER NOT NULL, + origin TEXT NOT NULL CHECK (origin 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_storage_account_in_use ON system_map(database_uuid ,database_uid) WHERE state = 'in use'; + +-- Every head of each storage database this system has seen: when a storage +-- account of it was made the same account as a system account, and at each +-- attach and detach. At attach, comparing the storage's record with these +-- heads tells whether the storage has moved on since, gone back to an older +-- copy, or gone sideways, a branch. +CREATE TABLE storage_head_seen ( 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 + series INTEGER NOT NULL, + head_hash TEXT NOT NULL, + event TEXT NOT NULL CHECK (event IN ('mapped' ,'attached' ,'detached')), + continuity TEXT, -- at attach: forward, backwards, sideways or unrelated + seen TEXT NOT NULL ); -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 ( +-- How many child accounts each system account may have below it. A system +-- account with no row may have none. +CREATE TABLE account_limit ( system_uid INTEGER PRIMARY KEY, maximum INTEGER NOT NULL ); diff --git a/developer/authored/AccountTree.copy/storage.py b/developer/authored/AccountTree.copy/storage.py index 6326772..fb61077 100644 --- a/developer/authored/AccountTree.copy/storage.py +++ b/developer/authored/AccountTree.copy/storage.py @@ -2,24 +2,27 @@ """ 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 /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. +A storage is a persistent storage device that keeps accounts' data: the disk +inside the machine, or a drive plugged in. Its tree dir is +/home/AccountTree, which holds the storage database and +account_data, the storage accounts' homes. On the disk a system runs from, the +tree dir is /home/AccountTree. -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. +Which storage accounts are the same accounts as system accounts here is +recorded in the system map of the system database, and attach mounts exactly +the accounts it lists. """ -import os ,shutil ,subprocess +import os ,shutil +from uuid import uuid4 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 database import Database +import kernel +from common import HOME ,SEPARATOR ,BRANCH_MARK ,act ,valid_name ,tree_dir_problem ,host_name from system import open_system_database ,DATABASE_UID_FLOOR -import same_user +import same_account # A storage database gives out database uids counting down from # DATABASE_UID_FIRST, while systems give out system uids counting up from low @@ -27,59 +30,59 @@ import same_user DATABASE_UID_FIRST = 1_999_999_999 -def open_storage_database(subu_dir ,log): - problem = subu_dir_problem(subu_dir) +def open_storage_database(tree_dir ,log): + problem = tree_dir_problem(tree_dir) if problem: - log.error(str(subu_dir) ,problem); return None - return Database.open(database.storage_database_path(subu_dir) ,"storage") + log.error(str(tree_dir) ,problem); return None + return Database.open(database.storage_database_path(tree_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", + return system_db.rows("SELECT * FROM system_map WHERE database_uuid = ? AND state = 'in use' ORDER BY system_account_name", (storage_db.uuid(),)) # -------------------------------------------------------------------------------- # storage make, show, delete -def storage_make(subu_dir ,log ,preview): - problem = subu_dir_problem(subu_dir) +def storage_make(tree_dir ,log ,preview): + problem = tree_dir_problem(tree_dir) if problem: - log.error(str(subu_dir) ,problem); return - subu_dir = Path(subu_dir) - home = subu_dir.parent + log.error(str(tree_dir) ,problem); return + tree_dir = Path(tree_dir) + home = tree_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) + path = database.storage_database_path(tree_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)): + for directory ,mode in ((home ,0o755) ,(tree_dir ,0o700)): if not directory.is_dir(): - if not preview: - directory.mkdir(mode=mode) - os.chmod(directory ,mode) + if not act(log ,preview ,str(directory) ,os.mkdir ,directory ,mode): return + if not act(log ,preview ,str(directory) ,os.chmod ,directory ,mode): return 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)) + account_data = database.account_data_dir(tree_dir) + if account_data.is_dir(): + log.line("kept" ,str(account_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") + if not act(log ,preview ,str(account_data) ,os.mkdir ,account_data ,0o700): return + if not act(log ,preview ,str(account_data) ,os.chmod ,account_data ,0o700): return + log.line("made" ,str(account_data) ,"root only, mode 0700") -def storage_show(subu_dir): - problem = subu_dir_problem(subu_dir) +def storage_show(tree_dir): + problem = tree_dir_problem(tree_dir) if problem: print(f"Error: {problem}"); return 1 - db = Database.open(database.storage_database_path(subu_dir) ,"storage") + db = Database.open(database.storage_database_path(tree_dir) ,"storage") d = db.identity() print(f"storage database uuid {d['database_uuid']}") + if d["branch_of"]: + print(f"branch of database {d['branch_of']}, after series {d['branch_after_series']}") print(f"file {db.path}") print(f"series {d['series']} head {d['head_hash'][:16]}") print(f"made {d['created']}") @@ -89,110 +92,116 @@ def storage_show(subu_dir): 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']}") + print("storage accounts") + for a in db.rows("SELECT * FROM account ORDER BY name"): + m = mapped.get(a["database_uid"]) + here = f"system account {m['system_account_name']}, system uid {m['system_uid']}" if m else "no system account here" + print(f" {a['name']:<28}database uid {a['database_uid']:<12}{a['state']:<10}{here}") + for n in db.rows("SELECT * FROM system_seen ORDER BY first"): + print(f"seen by system {n['system_uuid']} first {n['first']} last {n['last']}") 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): +def storage_delete(tree_dir ,delete_account_data ,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. + Undo on this system what AccountTree did for the storage database in + tree_dir, then delete the tree 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) + problem = tree_dir_problem(tree_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) + log.error(str(tree_dir) ,problem); return + tree_dir = Path(tree_dir) + account_data = database.account_data_dir(tree_dir) + path = database.storage_database_path(tree_dir) if not path.is_file(): - log.line("kept" ,str(subu_dir) ,"no storage database here; nothing to delete"); return + log.line("kept" ,str(tree_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")} + account_name = {a["database_uid"]: a["name"] for a in db.rows("SELECT database_uid ,name FROM account")} # ---- 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 n in db.rows("SELECT * FROM system_seen WHERE system_uuid IS NOT ?" ,(this_system_uuid,)): + problem.append(f"system {n['system_uuid'][:8]} has system accounts for storage accounts of this database, and its" + " system map would name a database that no longer exists; give the keyword forget-other-systems" + " to delete anyway") + in_session = kernel.session_system_uid_set() 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") + problem.append(f"{m['system_account_name']} has a login session") + for target in kernel.mount_point_list(): + if target.startswith(str(tree_dir) + "/"): + problem.append(f"{target} is a mount point inside {tree_dir}") + for entry in sorted(tree_dir.iterdir()): + if entry.name != "account_data" and not entry.name.startswith("database.sqlite3"): + problem.append(f"{entry} is not AccountTree'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") + if account_data.is_dir(): + for entry in sorted(account_data.iterdir()): + if entry.name not in account_name.values(): + problem.append(f"{entry} is not the home of a storage account 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 holding_file and not delete_account_data: + problem.append("these homes hold files: " + ", ".join(holding_file) + "; give the keyword delete-account-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") + for p in problem: log.error(str(tree_dir) ,p) + log.line("kept" ,str(tree_dir) ,"nothing was changed") return - # ---- this system's accounts for its users + # ---- this system's system accounts for its storage accounts 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 + if not same_account.same_account_end(m ,system_db ,tree_dir ,account_name.get(m["database_uid"] ,"?") ,log ,preview): + log.line("stopped" ,str(tree_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")) + if account_data.is_dir(): + home_count = sum(1 for _ in account_data.iterdir()) + if not act(log ,preview ,str(account_data) ,shutil.rmtree ,account_data): return + log.line("deleted" ,str(account_data) ,f"{home_count} home" + ("" if home_count == 1 else "s")) short = db.short() db.close() - for entry in sorted(subu_dir.iterdir()): + for entry in sorted(tree_dir.iterdir()): if entry.name.startswith("database.sqlite3"): - if not preview: entry.unlink() + if not act(log ,preview ,str(entry) ,entry.unlink): return 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)) + if not act(log ,preview ,str(tree_dir) ,tree_dir.rmdir): return + log.line("deleted" ,str(tree_dir)) # -------------------------------------------------------------------------------- -# user make +# account make -def user_make(subu_dir ,name ,database_uid ,masu ,log ,preview): +def account_make(tree_dir ,name ,database_uid ,parent ,make_system_account ,log ,preview): + """ + Make a storage account in the storage database in tree_dir, with its home in + account_data. Its parent is the account whose name its own begins with, + before the last '·', or the one given; an account with no '·' in its name + is a child of root. With make_system_account, also make a system account of + the same name and record that the two are the same account. + """ problem = valid_name(name) if problem: log.error(name ,problem); return - db = open_storage_database(subu_dir ,log) + db = open_storage_database(tree_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 db.row("SELECT 1 FROM account WHERE name = ?" ,(name,)): + log.line("kept" ,f"storage account {name}" ,"already in this database"); return + if parent is None and SEPARATOR in name: + parent = name.rsplit(SEPARATOR ,1)[0] + if parent is not None and not name.startswith(parent + SEPARATOR): + log.error(name ,f"the name of a child account of {parent} begins with '{parent}{SEPARATOR}'"); return + parent_row = db.row("SELECT database_uid FROM account WHERE name = ?" ,(parent,)) if parent else None + parent_database_uid = parent_row["database_uid"] if parent_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 + # kept with the keyword 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 @@ -201,104 +210,225 @@ def user_make(subu_dir ,name ,database_uid ,masu ,log ,preview): 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 + home = database.account_data_dir(tree_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: + with db.transaction(f"make storage account {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 "")) + t.execute("INSERT INTO account(database_uid ,name ,parent_database_uid ,parent_name ,state ,created ,changed)" + " VALUES (? ,? ,? ,? ,'active' ,? ,?)" ,(database_uid ,name ,parent_database_uid ,parent ,stamp ,stamp)) + log.line("made" ,f"storage account {name}" ,f"database uid {database_uid}" + (f", child of {parent}" if parent else ", child of root")) if home.exists(): - log.line("kept" ,str(home) ,"existing home directory, owned by its database uid") + log.line("kept" ,str(home) ,"existing home, owned by its database uid") else: - if not preview: - home.mkdir(mode=0o700) - os.chown(home ,database_uid ,database_uid) - os.chmod(home ,0o700) + if not act(log ,preview ,str(home) ,os.mkdir ,home ,0o700): return + if not act(log ,preview ,str(home) ,os.chown ,home ,database_uid ,database_uid): return + if not act(log ,preview ,str(home) ,os.chmod ,home ,0o700): return log.line("made" ,str(home) ,f"owned by database uid {database_uid}, mode 0700") + if make_system_account: + if preview: + # the storage account was not made, so the system map could not name it; + # say what would be done + log.line("made system account" ,name ,"with the next free system uid in this system's range") + log.line("mapped" ,f"{name} is {name}" ,"in the system map") + return + system_db = open_system_database(log) + if system_db is None: return + same_account.same_account_map(system_db ,db ,name ,name ,True ,log ,preview) + + +# -------------------------------------------------------------------------------- +# continuity and branches + +def continuity(system_db ,db): + """ + How the storage database's record relates to the heads this system has seen + of it. Returns (kind ,seen row): + new this system has seen no head of it + forward its record holds the last head seen: it has moved on since + backwards its head is one seen earlier: an older copy + sideways its record holds an earlier head seen, not the last one: a branch, + or an older copy whose head this system never saw + unrelated its record holds no head this system has seen + The seen row is the last head seen that its record holds, if any. + """ + seen = system_db.rows("SELECT * FROM storage_head_seen WHERE database_uuid = ? ORDER BY id" ,(db.uuid(),)) + if not seen: + return "new" ,None + if db.has_head(seen[-1]["series"] ,seen[-1]["head_hash"]): + return "forward" ,seen[-1] + series ,head_hash = db.head() + for s in reversed(seen): + if (s["series"] ,s["head_hash"]) == (series ,head_hash): + return "backwards" ,s + for s in reversed(seen): + if db.has_head(s["series"] ,s["head_hash"]): + return "sideways" ,s + return "unrelated" ,None + +def storage_branch(system_db ,db ,tree_dir ,log ,preview): + """ + Make the storage database a branch: give it a new database uuid, record the + database it came from and the head they parted after, and make a system + account ~ for each storage account that has a system + account on the line it came from. Both lines can then be attached at once. + """ + parent_uuid = db.uuid() + kind ,seen = continuity(system_db ,db) + after_series ,after_head = (seen["series"] ,seen["head_hash"]) if seen else (0 ,database.genesis_hash("storage" ,db.identity()["genesis_uuid"])) + in_use = system_map_in_use(system_db ,db) + new_uuid = str(uuid4()) + with db.transaction(f"branch from database {parent_uuid[:8]} after series {after_series}" ,preview) as t: + t.execute("UPDATE identity SET database_uuid = ? ,branch_of = ? ,branch_after_series = ? ,branch_after_head = ?", + (new_uuid ,parent_uuid ,after_series ,after_head)) + # the branch is named by the hash of the transaction that made it, which no + # other branch has; in a preview it is not known + suffix = "<8 hex>" if preview else db.head()[1][:8] + log.line("branched" ,f"database {parent_uuid[:8]}", + f"{'a new database uuid' if preview else 'now database ' + new_uuid[:8]}, after series {after_series}, {kind}") + for m in in_use: + a = db.row("SELECT name FROM account WHERE database_uid = ?" ,(m["database_uid"],)) + if a is None: continue + name = f"{m['system_account_name']}{BRANCH_MARK}{suffix}" + if preview: + log.line("made system account" ,name ,"with the next free system uid in this system's range") + log.line("mapped" ,f"{name} is {a['name']}" ,"of the branch") + continue + same_account.same_account_map(system_db ,db ,name ,a["name"] ,True ,log ,preview) + # -------------------------------------------------------------------------------- # attach and detach -def attach(subu_dir ,log ,preview): +def attach(tree_dir ,branch ,log ,preview): """ - Mount the home of each storage user that the system map makes the same - user as an account here, at /home/. + Mount the home of each storage account that the system map makes the same + account as a system account here, at /home/. + + The storage's record is compared with the heads this system has seen of it. + A storage gone back to an older copy, or sideways to a branch, is attached + with a warning: no one is kept from working. Given branch, the storage is + first made a branch of its own, with its own system accounts, so that it and + the line it came from can both be attached. A storage whose homes are + already mounted from another copy of it is refused, with that advice. """ system_db = open_system_database(log) if system_db is None: return - db = open_storage_database(subu_dir ,log) + db = open_storage_database(tree_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") + log.line("found" ,f"database {db.short()}" ,f"series {db.head()[0]}, chain verified") + + if branch: + storage_branch(system_db ,db ,tree_dir ,log ,preview) + if preview: + log.line("mounted" ,"the branch's homes" ,"at /home/~<8 hex>") + return + in_use = system_map_in_use(system_db ,db) - mapped = {m["database_uid"] for m in in_use} + if not in_use: + log.line("skipped" ,str(tree_dir) ,"no storage account of this database is the same account as a system account here") + return + + # the same database attached twice would mount two homes at one place + for m in in_use: + a = db.row("SELECT name FROM account WHERE database_uid = ?" ,(m["database_uid"],)) + target ,_ = same_account.mount_target(m) + source = database.account_data_dir(tree_dir) / (a["name"] if a else "") + if kernel.is_mount_point(target) and not same_account.is_mounted_from(target ,source): + log.error(str(target) ,"already mounted from another copy of this database, or from something else;" + " detach that, or attach this storage with the keyword branch. Nothing was mounted.") + return + + kind ,seen = continuity(system_db ,db) + series = db.head()[0] + if kind == "new": + log.line("found" ,f"database {db.short()}" ,"not attached here before") + elif kind == "forward": + log.line("found" ,f"database {db.short()}", + f"holds the head last seen here, series {seen['series']}; {series - seen['series']} transactions since") + elif kind == "backwards": + log.line("warning" ,f"database {db.short()}", + f"its head, series {series}, is the one seen here at {seen['seen']}, and another state of this" + " database has been attached here since: this is an older copy, or the other line of a branch." + " Merging the two lines is by hand") + elif kind == "sideways": + log.line("warning" ,f"database {db.short()}", + f"went sideways: it holds the head seen here at series {seen['series']}, but not the last one;" + " it is a branch, or an older copy. Merging the two lines is by hand") + else: + log.line("warning" ,f"database {db.short()}" ,"its record holds no head seen here; it parted before this system first saw it") + 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 + attach_account(tree_dir ,db ,m ,log ,preview) + mapped = {m["database_uid"] for m in in_use} + for a in db.rows("SELECT * FROM account WHERE state = 'active' ORDER BY name"): + if a["database_uid"] not in mapped: + log.line("skipped" ,f"storage account {a['name']}" ,"not the same account as any system account here") + same_account.system_seen_record(db ,system_db.uuid() ,log ,preview) + seen_series = same_account.head_seen_record(system_db ,db ,"attached" ,kind ,preview) + log.line("recorded" ,f"database {db.short()} attached" ,f"head at series {seen_series} remembered here") + +def attach_account(tree_dir ,db ,m ,log ,preview): + a = db.row("SELECT * FROM account WHERE database_uid = ?" ,(m["database_uid"],)) + if a is None or a["state"] != "active": + log.error(m["system_account_name"] ,f"the system map names database uid {m['database_uid']}," + " which is not an active storage account of this database"); return + target ,entry = same_account.mount_target(m) + if entry is None or entry.pw_name != m["system_account_name"]: + log.error(m["system_account_name"] ,f"no system account {m['system_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"] + log.error(m["system_account_name"] ,f"its home is {entry.pw_dir}; AccountTree mounts a home only at {target}"); return + source = database.account_data_dir(tree_dir) / a["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 kernel.is_mount_point(target): + log.line("kept" ,str(target) ,"already mounted"); 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) + if not act(log ,preview ,str(target) ,os.mkdir ,target ,0o000): return + if not act(log ,preview ,str(target) ,os.chmod ,target ,0o000): return + if not act(log ,preview ,str(target) ,kernel.immutable_set ,target ,True): return 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") + if not act(log ,preview ,str(target) ,kernel.home_mount ,source ,target ,database_uid ,m["system_uid"] ,m["system_gid"]): + return + how = ("bind mount, the database uid and system uid are the same" if database_uid == m["system_uid"] == m["system_gid"] + else f"idmapped, database uid {database_uid} to system uid {m['system_uid']}") + log.line("mounted" ,str(target) ,f"{how}; nosuid, nodev") -def detach(subu_dir ,log ,preview): +def detach(tree_dir ,log ,preview): system_db = open_system_database(log) if system_db is None: return - db = open_storage_database(subu_dir ,log) + db = open_storage_database(tree_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): + in_session = kernel.session_system_uid_set() + in_use = system_map_in_use(system_db ,db) + failed = False + for m in in_use: + a = db.row("SELECT name FROM account WHERE database_uid = ?" ,(m["database_uid"],)) + target ,_ = same_account.mount_target(m) + source = database.account_data_dir(tree_dir) / (a["name"] if a else "") + if not same_account.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.error(m["system_account_name"] ,"has a login session; not detached"); failed = True; continue + if act(log ,preview ,str(target) ,kernel.home_unmount ,target): log.line("unmounted" ,str(target)) + else: + failed = True + if in_use and not failed: + same_account.system_seen_record(db ,system_db.uuid() ,log ,preview) + seen_series = same_account.head_seen_record(system_db ,db ,"detached" ,None ,preview) + log.line("recorded" ,f"database {db.short()} detached" ,f"head at series {seen_series} remembered here") diff --git a/developer/authored/AccountTree.copy/system.py b/developer/authored/AccountTree.copy/system.py index ae89ba0..6f900f7 100644 --- a/developer/authored/AccountTree.copy/system.py +++ b/developer/authored/AccountTree.copy/system.py @@ -2,18 +2,19 @@ """ 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. +The system database, /var/lib/AccountTree/system.sqlite3, is this system's own +record: its system uuid, the range of system uids AccountTree gives out, the +system map of system accounts that are the same accounts as storage accounts, +every head of each storage database it has seen, and the limits on how many +child accounts each system account may have. """ import os -from pathlib import Path import database -from database import Database ,DatabaseError -from common import host_name ,account_by_system_uid ,account +from database import Database +from common import host_name ,act +from system_account import system_account ,system_account_by_system_uid DEFAULT_SYSTEM_UID_FIRST = 2000 DEFAULT_SYSTEM_UID_LAST = 59999 @@ -42,9 +43,8 @@ def system_make(system_uid_first ,system_uid_last ,log ,preview): 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) + if not act(log ,preview ,str(database.SYSTEM_DIR) ,os.makedirs ,database.SYSTEM_DIR ,0o700): return + if not act(log ,preview ,str(database.SYSTEM_DIR) ,os.chmod ,database.SYSTEM_DIR ,0o700): return 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: @@ -67,15 +67,21 @@ def system_show(): 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") + entry = system_account_by_system_uid(m["system_uid"]) + missing = m["state"] == "in use" and not (entry and entry.pw_name == m["system_account_name"]) + print(f" {m['system_account_name']:<26}system uid {m['system_uid']:<7}{m['origin']:<8}{m['state']:<8}" + f"database {m['database_uuid'][:8]} database uid {m['database_uid']}" + + (" (system account missing)" if missing else "")) + print("storage heads last seen") + for h in db.rows("SELECT h.* FROM storage_head_seen h WHERE h.id = (SELECT MAX(id) FROM storage_head_seen" + " WHERE database_uuid = h.database_uuid) ORDER BY h.seen"): + print(f" database {h['database_uuid'][:8]} series {h['series']} {h['event']} {h['seen']}" + + (f" ({h['continuity']})" if h["continuity"] else "")) + limit_row = db.rows("SELECT * FROM account_limit ORDER BY system_uid") if limit_row: print("limits") for l in limit_row: - entry = account_by_system_uid(l["system_uid"]) + entry = system_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() @@ -85,47 +91,47 @@ def system_show(): 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. + use: those system 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") + in_use = db.rows("SELECT * FROM system_map WHERE state = 'in use' ORDER BY system_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.error(str(path) ,f"{m['system_account_name']} is still the same account as database uid {m['database_uid']}" + f" of database {m['database_uuid'][:8]}; run is-not-same-account, 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() + if not act(log ,preview ,str(entry) ,entry.unlink): return 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: + if preview: log.line("deleted" ,str(path.parent) ,"when nothing else is in it") + elif not any(path.parent.iterdir()): + if act(log ,preview ,str(path.parent) ,path.parent.rmdir): + log.line("deleted" ,str(path.parent)) -def limit_set(account_name ,maximum ,log ,preview): +def limit_set(system_account_name ,maximum ,log ,preview): """ - Set how many SubUs an account may have below it on this system. + Set how many child accounts a system account may have below it. """ if maximum < 0: - log.error(account_name ,"a limit is 0 or more"); return + log.error(system_account_name ,"a limit is 0 or more"); return db = open_system_database(log) if db is None: return - entry = account(account_name) + entry = system_account(system_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,)) + log.error(system_account_name ,"no such system account"); return + old = db.row("SELECT maximum FROM account_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 (? ,?)" + log.line("kept" ,f"{system_account_name} limit {maximum}" ,"already set"); return + with db.transaction(f"set limit of {system_account_name} to {maximum}" ,preview) as t: + t.execute("INSERT INTO account_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) + log.line("set" ,f"{system_account_name} limit {maximum}" ,was) diff --git a/developer/authored/AccountTree.copy/system_account.py b/developer/authored/AccountTree.copy/system_account.py new file mode 100644 index 0000000..80eecb9 --- /dev/null +++ b/developer/authored/AccountTree.copy/system_account.py @@ -0,0 +1,110 @@ +# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- +""" +system_account - reading and changing the accounts of this system + +Reading is done directly, through the C library's account functions, which +Python's pwd and grp modules call. Changing accounts is, for now, done by the +shadow suite's commands, useradd, userdel and groupdel, each wrapped in a +function named for what it does here. + +In the future each change will be a direct function call: the files +/etc/passwd, /etc/shadow, /etc/group and /etc/gshadow edited under the C +library's lock, lckpwdf, by writing each new file beside the old one and +renaming it into place, so that a reader never sees a file half written. +""" + +import pwd ,grp ,subprocess +from pathlib import Path + +HOME = Path("/home") + + +class SystemAccountError(Exception): + pass + + +# -------------------------------------------------------------------------------- +# reading + +def system_account(name): + try: + return pwd.getpwnam(name) + except KeyError: + return None + +def system_account_by_system_uid(system_uid): + try: + return pwd.getpwuid(system_uid) + except KeyError: + return None + +def system_account_list(): + return sorted(pwd.getpwall() ,key=lambda e: e.pw_name) + +def system_group(name): + try: + return grp.getgrnam(name) + except KeyError: + return None + +def system_group_by_system_gid(system_gid): + try: + return grp.getgrgid(system_gid) + 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 system_uid_min() -> int: + """ + The lowest system uid of an account a person logs in to; below it are the + accounts of the system's own services. + """ + return login_defs_value("UID_MIN" ,1000) + + +# -------------------------------------------------------------------------------- +# changing + +def run_shadow(command): + result = subprocess.run(command ,capture_output=True ,text=True) + if result.returncode != 0: + raise SystemAccountError(f"{' '.join(command)}: {result.stderr.strip() or 'exit ' + str(result.returncode)}") + +def system_account_make(name ,system_uid): + """ + Make a system account and its own group, both numbered system_uid, with its + home at /home/, which is not made: attach mounts the home there. + + In the future this will be a direct function call: lines added to + /etc/passwd, /etc/shadow, /etc/group and /etc/gshadow under lckpwdf. + """ + run_shadow(["useradd" ,"--badname" ,"-M" ,"-d" ,str(HOME / name) ,"-s" ,"/bin/bash", + "-u" ,str(system_uid) ,"-U" ,name]) + +def system_account_delete(name): + """ + Delete a system account, leaving its home alone: the home belongs to the + storage it is mounted from. + + In the future this will be a direct function call: its lines removed from + /etc/passwd and /etc/shadow under lckpwdf. + """ + run_shadow(["userdel" ,name]) + +def system_group_delete(name): + """ + Delete a system group. + + In the future this will be a direct function call: its lines removed from + /etc/group and /etc/gshadow under lckpwdf. + """ + run_shadow(["groupdel" ,name]) diff --git a/developer/authored/AccountTree.copy/window.py b/developer/authored/AccountTree.copy/window.py index 409c154..42ad269 100644 --- a/developer/authored/AccountTree.copy/window.py +++ b/developer/authored/AccountTree.copy/window.py @@ -1,11 +1,12 @@ # -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*- """ -window - the SubU resource window +window - the AccountTree 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. +One window shows the resources of one account, the window account, and acts +with the authority of another, the acting account: the account of the person +who started the tool. The first window is for the acting account. Clicking a +child account opens that child account's window, still acting with the first +account'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 @@ -15,12 +16,12 @@ 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 account_tree import authority import resource_kind GTK_MISSING = """\ -SubU window: GTK 4 for Python is not available to {python} +AccountTree 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) @@ -28,15 +29,15 @@ This Python needs the PyGObject package and the GTK 4 introspection data. python3 -m pip install PyGObject """ -APPLICATION_ID = "com.ReasoningTechnology.SubU" +APPLICATION_ID = "com.ReasoningTechnology.AccountTree" 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 + # window of buttons gains nothing from the graphics chip, and a child account + # 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 @@ -52,11 +53,11 @@ def load_gtk(): # 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 { +frame.account-tree-panel > border { border: 1px solid alpha(currentColor ,0.45); border-radius: 6px; } -frame.subu-panel > label { +frame.account-tree-panel > label { font-weight: bold; } """ @@ -84,7 +85,7 @@ def build_window_class(Gtk ,GLib): A framed panel and the box its contents go in. """ frame = Gtk.Frame(label=title) - frame.add_css_class("subu-panel") + frame.add_css_class("account-tree-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) @@ -124,32 +125,32 @@ def build_window_class(Gtk ,GLib): def authority_line(self): """ Says whose authority the window acts with, when it is not the window - user's own. + account's own. """ - if self.acting_user.name == self.window_user.name: + if self.acting_account.name == self.window_account.name: return None return self.label( - f"acting with the authority of {escape(self.acting_user.name)}" ,markup=True) + f"acting with the authority of {escape(self.acting_account.name)}" ,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 + the window account. 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): + def __init__(self ,parent ,group ,window_account ,acting_account): super().__init__(transient_for=parent) - self.window_user = window_user - self.acting_user = acting_user + self.window_account = window_account + self.acting_account = acting_account title = resource_kind.GROUP_TITLE[group] - self.set_title(f"SubU: {window_user.name}: allocate {title}") + self.set_title(f"AccountTree: {window_account.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"{escape(window_user.name)}" ,markup=True)) + box.append(self.label(f"{escape(window_account.name)}" ,markup=True)) line = self.authority_line() if line: box.append(line) @@ -159,7 +160,7 @@ def build_window_class(Gtk ,GLib): for i ,kind in enumerate(kind_list): action = None if kind.implemented: - action = lambda k=kind: k.allocator(self.window_user ,self.acting_user) + action = lambda k=kind: k.allocator(self.window_account ,self.acting_account) 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) @@ -177,21 +178,21 @@ def build_window_class(Gtk ,GLib): class ResourceWindow(PanelHelper ,Gtk.ApplicationWindow): - def __init__(self ,application ,window_user ,acting_user): + def __init__(self ,application ,window_account ,acting_account): super().__init__(application=application) - self.window_user = window_user - self.acting_user = acting_user - self.set_title(f"SubU: {window_user.name}") + self.window_account = window_account + self.acting_account = acting_account + self.set_title(f"AccountTree: {window_account.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 + # row 0 Account Authority Control + # row 1 Child account 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_child_account() ,0 ,1 ,1 ,1) grid.attach(self.panel_allocate() ,1 ,1 ,1 ,1) grid.attach(self.panel_allocated(),2 ,1 ,1 ,1) @@ -206,15 +207,17 @@ def build_window_class(Gtk ,GLib): # panels def panel_identity(self): - frame ,box = self.panel("User") - name = self.window_user.name - masu = account.masu_of(name) + frame ,box = self.panel("Account") + name = self.window_account.name + parent = account_tree.parent_of(name) box.append(self.label(f"{escape(name)}" ,markup=True)) - if masu: - relation = f"SubU of {masu}" + if not parent: + relation = "root, at the top of the account tree" + elif parent == "root": + relation = "login account, a child of root" else: - relation = "the system's root user, MasU of every person account" - box.append(self.label(f"uid {self.window_user.uid} {relation}")) + relation = f"child account of {parent}" + box.append(self.label(f"system uid {self.window_account.uid} {relation}")) line = self.authority_line() if line: box.append(line) return frame @@ -232,76 +235,77 @@ def build_window_class(Gtk ,GLib): box.append(button) return frame - def panel_subu(self): + def panel_child_account(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. + One row per child account of the window account: a button with its + short name, which opens its window, and its state. The states are read + again every few seconds, so a session starting or ending shows without + reopening the window. """ - frame ,box = self.panel("SubU") - subu_list = account.subu_list(self.window_user.name) - if not subu_list: + frame ,box = self.panel("Child account") + child_list = account_tree.child_account_list(self.window_account.name) + if not child_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)) + self.child_state_label = [] + session_set = account_tree.session_system_uid_set() + for i ,child in enumerate(child_list): + button = Gtk.Button(label=account_tree.short_name(child.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)) + f"open the resource window of {child.name}, " + f"acting with the authority of {self.acting_account.name}") + button.connect("clicked" ,lambda b ,c=child: self.open_child_account(c)) grid.attach(button ,0 ,i ,1 ,1) - state = self.label(account.state(subu ,session_uids)) + state = self.label(account_tree.state(child ,session_set)) grid.attach(state ,1 ,i ,1 ,1) - self.subu_state_label.append((subu ,state)) + self.child_state_label.append((child ,state)) box.append(grid) - GLib.timeout_add_seconds(STATE_REFRESH_SECONDS ,self.refresh_subu_state) + GLib.timeout_add_seconds(STATE_REFRESH_SECONDS ,self.refresh_child_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)) + def refresh_child_state(self): + session_set = account_tree.session_system_uid_set() + for child ,label in self.child_state_label: + label.set_text(account_tree.state(child ,session_set)) 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})")) + window_account = self.window_account + state = "present" if window_account.home_exists else "not found" + box.append(self.label(f"home {window_account.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. + What can be done to the window account as a whole: its sessions, and + its 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("enter", + "open a shell or desktop session in this account (the old subu_login script does this today)"), + self.option("end sessions" ,"end every session and service of this account"), self.option("take sound", - "give this user the sound card lease (subu_sound does this today)"), + "give this account the sound card lease (the old subu_sound script 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("rename" ,"give this account a new name; its uid and files stay"), + self.option("deprecate" ,"lock this account 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"), + "remove this account 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) + acting = self.acting_account + box.append(self.label(f"acting account {acting.name} (system uid {acting.uid})")) + decision = authority.request(acting.name ,"view" ,self.window_account.name) box.append(self.label(f"authority {decision.reason}" ,dim=True)) return frame @@ -309,10 +313,10 @@ def build_window_class(Gtk ,GLib): # actions def open_allocate(self ,group): - AllocateWindow(self ,group ,self.window_user ,self.acting_user).present() + AllocateWindow(self ,group ,self.window_account ,self.acting_account).present() - def open_subu(self ,subu): - ResourceWindow(self.get_application() ,subu ,self.acting_user).present() + def open_child_account(self ,child): + ResourceWindow(self.get_application() ,child ,self.acting_account).present() return ResourceWindow @@ -321,7 +325,7 @@ def escape(text): return text.replace("&" ,"&").replace("<" ,"<").replace(">" ,">") -def run(window_user ,acting_user): +def run(window_account ,acting_account): import sys try: Gtk ,Gio ,GLib = load_gtk() @@ -334,5 +338,5 @@ def run(window_user ,acting_user): application = Gtk.Application(application_id=APPLICATION_ID, flags=Gio.ApplicationFlags.NON_UNIQUE) application.connect("activate", - lambda app: ResourceWindow(app ,window_user ,acting_user).present()) + lambda app: ResourceWindow(app ,window_account ,acting_account).present()) return application.run([]) diff --git a/developer/authored/replace_from_tar b/developer/authored/replace_from_tar deleted file mode 100755 index 6da5c0f..0000000 --- a/developer/authored/replace_from_tar +++ /dev/null @@ -1,182 +0,0 @@ -#!/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 [argument ...] - replace_from_tar preview [argument ...] - help | -h | --help print this message (also printed when given no arguments) - replace [] - bring each directory at the top of the tar file, - in the extract dir, to match the tar file - preview print the log of what 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/document/design_AccountTree.html b/document/design_AccountTree.html index cade903..e4ec119 100644 --- a/document/design_AccountTree.html +++ b/document/design_AccountTree.html @@ -67,11 +67,21 @@
  • account: a Linux account, with its own uid, home, processes and sessions.
  • account tree: the accounts of a system, with root at the top.
  • parent account: the account directly above an account. child account: an account directly below one.
  • -
  • login account: 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.
  • -
  • 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.
  • +
  • login account: a child account of root.
  • leaf account: an account with no child accounts.
  • +

    + Every account is entered through its parent. Root enters its child accounts through the system's login programs, the display manager, login at the console and sshd, which run as root and start a session for a person who gives a password or a key. A login account enters its child accounts through the AccountTree server, which starts sessions and programs in them; an account below a login account has no password, and the login programs do not enter it. The rule is the same at every level of the tree. Only the means differ, and the means for root's children are the ones Linux already has. +

    + +

    + A parent account reaches a child account's files through a view. +

    + +
      +
    +

    An account's name is its parent's name, the middle dot, and its own short name: Thomas·developer·scratch is a child of Thomas·developer, which is a child of the login account Thomas, which is a child of root. In identifiers the wider category comes first, so prose says child account and parent account, and code says account_child and account_parent.

    @@ -79,7 +89,7 @@

    Terms

      -
    • storage: 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.
    • +
    • storage: 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 can be moved between machines.
    • tree dir: the directory <storage root>/home/AccountTree, holding the storage database and the accounts' homes. On the disk a system runs from it is /home/AccountTree. AccountTree_admin is given a tree dir to name a storage.
    • system: an installed, running Linux, with its own /etc/passwd, its own uid numbering, and its own /home, into which the accounts kept on attached storage are mounted.
    • system account: an account in a system's /etc/passwd. storage account: an account in a storage database.
    • @@ -92,7 +102,8 @@
    • account specifier: the name of an account in one place: system <host name> <system account>, or storage <tree dir> <storage account>.
    • same account: 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.
    • view: an idmapped mount through which a parent account sees a child account's files as its own.
    • -
    • server: the program, running as root, that alone changes accounts, databases and mounts on behalf of persons.
    • +
    • server: the program that changes accounts, databases and mounts on behalf of persons. It runs as an unprivileged account of its own, AccountTree, and makes every change to the system through journal interpreters.
    • +
    • journal interpreter: a small privileged program of Man-In-Grey's design that receives a journal of commands, has its guard check every command against its constraints, and only then executes them. AccountTree has one bespoke journal interpreter for each of its command languages. design_Man-In-Grey.html gives the design.
    • unmanaged account: an account AccountTree does not manage, such as root, a service's account, or an account made with useradd by hand.
    @@ -101,7 +112,7 @@

    Account names

    - The middle dot (U+00B7, written ·) may not appear in a short name, so a full name divides in exactly one way, and the conventional characters _, - and . remain free for use in names. It lies outside the POSIX portable character set. Making an account needs the --badname flag of useradd, 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 utmp login records and by useradd, 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 shared/project-dependency/Python.org. + The middle dot (U+00B7, written ·) is not permitted in a short name, so a full name divides in exactly one way, and the conventional characters _, - and . remain free for use in names. It lies outside the POSIX portable character set. Making an account needs the --badname flag of useradd, 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 is currently limited to 32 bytes. That limit is set in user space, by the fixed name field of the utmp login records and by useradd, 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 shared/project-dependency/Python.org.

    @@ -151,7 +162,7 @@

    Every storage is the same

    - 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. + A storage is a repository of accounts' data, whether it is built into the machine or plugged in, and a machine can 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.

    Copies and branches

    @@ -211,7 +222,7 @@

    - 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. + A storage database never reuses a database uid. A database uid given up by a deallocated account stays retired, because files carrying it can remain on the storage, in a backup, or on another storage they were copied to.

    Limits of uids

    @@ -244,7 +255,7 @@

    - 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 /etc/subuid. 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. + 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 /etc/subuid. 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 tree dir, which only the server's account and root can enter. 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.

    @@ -307,9 +318,9 @@

  • identity: 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.
  • standard: as for a storage database.
  • system_uid_range: the first and last system uid AccountTree gives a system account it makes. One row.
  • -
  • system_map: 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.
  • +
  • system_map: 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 can 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.
  • storage_head_seen: every head of each storage database this system has seen, with the event (mapped, attached, detached), and at attach, the continuity found.
  • -
  • account_limit: the number of child accounts each system account may have below it, by system uid. A system account with no row may have none.
  • +
  • account_limit: the number of child accounts each system account is allowed below it, by system uid. A system account with no row is allowed none.
  • transaction_log: the record of every change.
  • @@ -324,7 +335,7 @@

    - 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. + 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 only by the server's account and root, so the record is the database's own history, as a git repository's history is, rather than a log for persons to read.

    @@ -338,7 +349,7 @@

    Standards

    - A program that opens a database first reads its standard 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. + A program that opens a database first reads its standard table, and refuses a database that follows no standard the program follows. During a change from one standard to the next, a database lists both.

    Fallback tools

    @@ -364,7 +375,7 @@

    - A keyword such as make-system-account 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 UID_MIN, its home must be /home/<system account>, and no other storage account may already be the same account as it. When it does not exist, the keyword make-system-account 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 /home/<system account> 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. + A keyword such as make-system-account is a word given after a command's other arguments, as the command itself is a word, not a flag. The two account specifiers are accepted in either order. In 5.0 the pair is a system account and 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 UID_MIN, its home must be /home/<system account>, and it must not already be the same account as another storage account. When it does not exist, the keyword make-system-account 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 /home/<system account> 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.

    @@ -380,7 +391,7 @@

    - 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. + Two storage databases can record that an account of one is the same account as an account of the other, and can record 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.

    @@ -392,7 +403,11 @@

    What it is

    - 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, /run/AccountTree/socket. The socket is open to every account; the server decides what each request may do. + The server is the one program that changes accounts, databases and mounts on behalf of persons. It runs as a system service under an account of its own, AccountTree, which holds no privilege, and receives requests from any account over a Unix domain socket, /run/AccountTree/socket. The socket is open to every account; the server decides what each request is allowed to do. +

    + +

    + The server owns AccountTree's records: the system database, /var/lib/AccountTree/system.sqlite3, and on each storage the tree dir and its storage database. It reads and writes them with its own rights. Every change to the system itself, an account in /etc/passwd, a home directory owned by a database uid, a mount, a session in another account, needs privilege the server does not have. For those the server composes a journal and hands it to a bespoke journal interpreter, through the interpreter's gate, following the design of Man-In-Grey.

    @@ -405,10 +420,14 @@ The server learns the requesting account from the kernel. After a client connects, the server reads the SO_PEERCRED socket option, which gives the process id, uid and gid the kernel recorded for the client when it called connect. 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.

    +

    + The requesting account is the principal of every journal the server composes for the request. A journal interpreter sees the server's account as its caller, and accepts a principal named in a journal only because the server's account is listed, in a table root owns, as entitled to name one. So the decision about who asked rests on the kernel at the socket, and the decision about what is allowed rests on the journal interpreter's guardrails. +

    +

    Requests

    - 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. + Each request is checked, recorded as a database transaction, carried out through journals, and answered. An account is allowed to 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.

      @@ -418,17 +437,35 @@
    • deallocate <account>: 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.
    • view [<account>]: report an account and its tree: names, states, storage, and whether each is mounted. Changes nothing.
    • set views: mount the requesting account's views of its child accounts that are on attached storage. Views already mounted are left as they are.
    • +
    • enter <account> [<program>]: start a session, or a program, in an account below the requesting account.
    • reconcile: compare the databases with /etc/passwd and the mount table, complete what an interrupted request left undone, make again any system account lost from /etc/passwd, and report what differs. Run at start and on demand. Administrator only.

    - The administrator's commands are not requests. AccountTree_admin 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 AccountTree_admin may both write a database; SQLite's lock lets one transaction in at a time. + The administrator's commands are not requests. AccountTree_admin runs as root, so it can change the system directly, but it composes the same journals and hands them to the same journal interpreters, with root as caller and principal. The code that changes the system then lives in one place, the journal interpreters, and the constraints catch the administrator's mistakes as they catch the server's. Making accounts the same, attaching, detaching and setting limits work while the server is stopped, at boot for instance. The server and AccountTree_admin both write the databases; SQLite's lock lets one transaction in at a time. +

    + +

    Command languages and constraints

    + +

    + Each bespoke journal interpreter accepts one command language, and its guard refuses a whole journal when any command in it breaks a constraint. Every constraint is checked against the system database, which the journal interpreters read, and against the system itself. None of them trusts the server's account of what the records say. +

    + +
      +
    • Account. Commands: make a system account with a given name and system uid; delete a system account; delete its group. Constraints: the name is a valid account name; a new system uid lies in AccountTree's range and appears in no row of the system map; a system account is deleted only when the system map records that AccountTree made it. Edits /etc/passwd, /etc/shadow, /etc/group and /etc/gshadow.
    • +
    • Home. Commands: make a storage account's home in account_data, owned by its database uid, mode 0700; delete a home and its files. Constraints: the tree dir holds a storage database whose database uuid the system database knows, and the storage account exists there with that database uid; a home is deleted only during the deletion of its storage, or of a deallocated account.
    • +
    • Mount. Commands: make and delete a mount point; mount and unmount a home; mount and unmount a view. Constraints: a home is mounted only from <tree dir>/account_data/<storage account> onto /home/<system account>, where a row of the system map in use pairs the two, with that row's uid translation; a view is mounted only of a child account's home, at ~/account_child/<short name> of its parent; every mount is nosuid and nodev; a mount point is an empty directory owned by root with mode 0000, made and deleted only at /home/<system account>.
    • +
    • Session. Commands: start a session, or a program, in an account. Constraint: the principal is above the account in the tree.
    • +
    + +

    + Each command language needs a different part of root's rights: the account language to write files in /etc, the mount language to mount, the session language to change uid. Separate journal interpreters let each hold only its own part, as Man-In-Grey's design on least privilege describes.

    Partial failure

    - 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; reconcile finishes the work or reports why it cannot. A request is complete only when the system matches the databases. + A request makes several changes. The journal for a request is approved whole before any command executes, so a request that breaks a constraint changes nothing on the system. A command can still fail while executing, after earlier commands succeeded. The databases record the intended state of each account, so such a failure leaves the intended state recorded and the system short of it; reconcile finishes the work or reports why it cannot. A request is complete only when the system matches the databases.

    Protocol

    @@ -440,9 +477,14 @@

    The service unit

    - The server's systemd unit must not set PrivateMounts, ProtectHome 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. + The server's systemd unit runs it with User=AccountTree. Two kinds of systemd option must be left out, because each would quietly stop the journal interpreters from working:

    +
      +
    • Options that give the service a mount namespace of its own, such as PrivateMounts or ProtectHome. The journal interpreters the server starts inherit its mount namespace, and a mount made there exists only for the service; no account ever sees it.
    • +
    • NoNewPrivileges, and the options that imply it for a service not running as root. With it set, the kernel ignores the setuid bit and file capabilities of every program the service starts, so the gates run without privilege.
    • +
    +

    Limits

    @@ -462,7 +504,7 @@

    - AccountTree makes the mount with the kernel's own calls, not by running mount. open_tree clones the source as a mount not yet attached anywhere; mount_setattr gives it nosuid, nodev and the idmap; move_mount 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 mount_setattr. A map line reads <id inside> <id outside> <count>; 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. + The mount journal interpreter makes the mount with the kernel's own calls, not by running mount. open_tree clones the source as a mount not yet attached anywhere; mount_setattr gives it nosuid, nodev and the idmap; move_mount 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 mount_setattr. A map line reads <id inside> <id outside> <count>; 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.

    Attaching storage

    @@ -503,7 +545,7 @@

    - 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. + An account below a login account is entered through its parent: the parent sends the server an enter request, and the server composes a journal for the session journal interpreter, whose constraint allows the session only when the principal is above the account in the tree. Whether such an account is ever to be entered through the login programs as well is open.

    @@ -514,14 +556,15 @@

    • AccountTree: the person's program. Its commands send requests to the server; its window 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.
    • +
    • AccountTree_server: the server, started by its systemd unit as the account AccountTree. Nobody types it.
    • AccountTree_admin: the administrator's program. Root. Its commands: system make, system show and system delete; storage make, storage show and storage delete, each given a tree dir; account make; is-same-account, is-not-same-account, same-account show and same-account suggest; attach and detach; and limit set. At boot, before logins are allowed, attach is run for every storage present, the disk inside the machine included, so that /home is populated. Detaching is refused while a system account it would unmount has a session. Unlocking an encrypted storage and mounting it come later.
    • -
    • AccountTree_server: the server, started by its systemd unit. Nobody types it.
    • +
    • The bespoke journal interpreters, one for each command language, account, home, mount and session, each a compiled gate followed by a guard and Man-In-Grey's general interpreter. Installed owned by root, in directories only root can write.
    • home_present: the PAM check.
    • The schema and initialization scripts.

    - 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; AccountTree_admin and the server import them. The person's functions send requests to the server and return its replies; AccountTree and the window import them. The socket between the layers is where privilege changes. + 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 three layers, and two boundaries between them. The person's functions send requests to the server and return its replies; AccountTree and the window import them. The socket between the person and the server is where authority is decided: the server learns who asked and what that account is allowed. The server's functions keep the records and compose journals; the server and AccountTree_admin import them. The gate between the server and a journal interpreter is where privilege changes, and the guard decides, by the constraints, what the privilege is used for. The journal interpreters hold the functions that change the system.

    @@ -529,21 +572,23 @@

      -
    • kernel: direct calls, made through ctypes. The idmapped mount through open_tree, mount_setattr and move_mount; unmounting through umount2; the mount table from /proc/self/mountinfo; the immutable flag through the FS_IOC_SETFLAGS ioctl; login sessions through sd_get_uids in libsystemd.
    • -
    • system_account: reading system accounts directly, through the C library, and changing them through wrappers, system_account_make, system_account_delete and system_group_delete, which run the shadow suite's commands for now. In the future each will be a direct call: the lines of /etc/passwd, /etc/shadow, /etc/group and /etc/gshadow edited under the C library's lock, lckpwdf, each new file written beside the old one and renamed into place.
    • -
    • database, system, storage and same_account: the databases and the work functions on them.
    • +
    • kernel: direct calls, made through ctypes. The idmapped mount through open_tree, mount_setattr and move_mount; unmounting through umount2; the mount table from /proc/self/mountinfo; the immutable flag through the FS_IOC_SETFLAGS ioctl; login sessions through sd_get_uids in libsystemd. Its changing calls move into the mount journal interpreter; its reading calls stay available to the server.
    • +
    • system_account: reading system accounts directly, through the C library, and changing them through wrappers, system_account_make, system_account_delete and system_group_delete, which run the shadow suite's commands for now. In the future each will be a direct call: the lines of /etc/passwd, /etc/shadow, /etc/group and /etc/gshadow edited under the C library's lock, lckpwdf, each new file written beside the old one and renamed into place. The changing calls move into the account journal interpreter.
    • +
    • database, system, storage and same_account: the databases and the work functions on them, which compose journals where they now change the system directly.
    • account_tree, authority, resource_kind and window: the person's side.

    - 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. + In the code as it stands, AccountTree_admin calls the changing functions directly, as root. Moving them into journal interpreters is the step that lets the server run without privilege. Keeping them small and their calls direct also lets them later be moved into the kernel, where an account tree with root at its top fits Linux as it is.

    Open questions

    • What deallocation does with an account's data.
    • -
    • Whether a child account may ever be logged in to directly.
    • +
    • When each AccountTree journal interpreter moves from setuid root to only the capabilities its command language needs: CAP_SYS_ADMIN for the mount language, CAP_SETUID and CAP_SETGID for the session language, and the file capabilities for the account and home languages.
    • +
    • The names of the journal interpreter programs.
    • +
    • Whether an account below a login account is ever to be entered through the login programs as well.
    • Whether a parent account's views are removed when its last session ends, or left until the storage is detached.
    • How resources other than accounts and storage, such as network zones and devices, are recorded in the database.
    @@ -603,7 +648,7 @@

    Deep freeze

    - 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. + A person's accounts, archives included, do not always 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.

    diff --git a/shared/tool/replace_from_tar b/shared/tool/replace_from_tar new file mode 100755 index 0000000..6da5c0f --- /dev/null +++ b/shared/tool/replace_from_tar @@ -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 [argument ...] + replace_from_tar preview [argument ...] + help | -h | --help print this message (also printed when given no arguments) + replace [] + bring each directory at the top of the tar file, + in the extract dir, to match the tar file + preview print the log of what 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())