+++ /dev/null
-# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-"""
-FileLanguage - a small instruction language for writing files, Man_In_Grey's
-test language. It is a constrained form of the POSIX file interface.
-
- open(directory ,name ,mode ,owner ,permission) produces a symbol for the file
- write(file ,content) appends content to the file
- unlink(directory ,name) removes a file
-
-The modes of open:
-
- create the file must not exist
- replace an existing file is discarded
- replace_keep an existing file is kept as <name>.YYYYMMDDTHHMMSSZ
-
-There is no close. Each opened file is written to a temporary file in its
-directory, and after the journal's last instruction every opened file is put in
-place, each in one step.
-
-The module has two halves. The composer half is JournalInterface, through
-which a composer places instructions; it checks every argument when the call is
-made, and raises JournalError on a wrong one. The interpreter half holds the
-checks the guard makes of a journal received as data, and the execution the
-interpreter carries out.
-
-The constraints are predicates written at the top of the guard,
-guard_file.py: is_allowed_account, is_allowed_open and is_allowed_unlink.
-"""
-
-import os ,pwd ,stat ,time
-from pathlib import Path
-
-from Journal import JournalError ,Symbol ,has_control ,clean
-
-NAME = "file"
-
-MODE = ("create" ,"replace" ,"replace_keep")
-
-ARGUMENT = {
- "open": ("directory" ,"name" ,"mode" ,"owner" ,"permission")
- ,"write": ("file" ,"content")
- ,"unlink": ("directory" ,"name")
-}
-
-
-# --------------------------------------------------------------------------------
-# argument checks, shared by both halves
-
-def directory_problem(value):
- if not isinstance(value ,str) or not value.startswith("/"):
- return "directory is to be an absolute path"
- if has_control(value):
- return f"directory {clean(value)} holds a control character"
- if os.path.normpath(value) != value:
- return f"directory {value!r} is not in normal form; give {os.path.normpath(value)!r}"
- return None
-
-def name_problem(value):
- if not isinstance(value ,str) or not value or "/" in value or value in ("." ,".."):
- return f"name {clean(repr(value))} is not a bare file name"
- if has_control(value):
- return f"name {clean(value)} holds a control character"
- return None
-
-def owner_problem(value):
- if not isinstance(value ,str) or not value or has_control(value):
- return "owner is to be an account name"
- return None
-
-def permission_value(value):
- "an int 0..0o7777, or a string of 3 or 4 octal digits; None when neither"
- if isinstance(value ,bool):
- return None
- if isinstance(value ,int):
- return value if 0 <= value <= 0o7777 else None
- if isinstance(value ,str) and len(value) in (3 ,4) and all(c in "01234567" for c in value):
- return int(value ,8)
- return None
-
-
-# --------------------------------------------------------------------------------
-# composer half
-
-class JournalInterface:
- """
- The journal interface of the file language. A composer is given one, and
- places instructions with its three functions. open returns a Symbol: the file
- the open instruction will produce when the journal is executed.
- """
-
- def __init__(self ,journal ,working_dpath):
- self._journal = journal
- self._working_dpath = Path(working_dpath)
-
- def _directory(self ,directory):
- if not isinstance(directory ,(str ,Path)) or str(directory) == "":
- raise JournalError("directory is to be a path")
- path = Path(directory)
- if not path.is_absolute():
- path = self._working_dpath / path
- value = os.path.normpath(str(path))
- problem = directory_problem(value)
- if problem: raise JournalError(problem)
- return value
-
- def open(self ,directory ,name ,mode ,owner ,permission):
- directory = self._directory(directory)
- problem = name_problem(name)
- if problem: raise JournalError(f"open: {problem}")
- if mode not in MODE:
- raise JournalError(f"open: mode {mode!r} is not one of {', '.join(MODE)}")
- problem = owner_problem(owner)
- if problem: raise JournalError(f"open: {problem}")
- value = permission_value(permission)
- if value is None:
- raise JournalError(f"open: permission {permission!r} is not an integer 0 to 0o7777 or three or four octal digits")
- return self._journal.place("open" ,{
- "directory": directory ,"name": name ,"mode": mode ,"owner": owner ,"permission": value
- })
-
- def write(self ,file ,content):
- if not isinstance(file ,Symbol) or self._journal.instruction_list[file.index].name != "open":
- raise JournalError("write: file is to be the symbol an open returned")
- if isinstance(content ,str):
- content = content.encode("utf-8")
- if not isinstance(content ,(bytes ,bytearray)):
- raise JournalError("write: content is to be text or bytes")
- self._journal.place("write" ,{"file": file ,"content": bytes(content)})
-
- def unlink(self ,directory ,name):
- directory = self._directory(directory)
- problem = name_problem(name)
- if problem: raise JournalError(f"unlink: {problem}")
- self._journal.place("unlink" ,{"directory": directory ,"name": name})
-
-
-# --------------------------------------------------------------------------------
-# interpreter half: checks, each returning a list of problems
-
-def check_wellformed(data):
- problem_list = []
- for index ,instruction in enumerate(data["instruction_list"]):
- name ,argument = instruction["name"] ,instruction["argument"]
- if name not in ARGUMENT:
- problem_list.append(f"@{index}: unknown instruction {clean(repr(name))}"); continue
- if set(argument) != set(ARGUMENT[name]):
- problem_list.append(f"@{index} {name}: arguments {clean(sorted(argument))}, expected {sorted(ARGUMENT[name])}"); continue
- if name in ("open" ,"unlink"):
- for problem in (directory_problem(argument["directory"]) ,name_problem(argument["name"])):
- if problem: problem_list.append(f"@{index} {name}: {problem}")
- if name == "open":
- if argument["mode"] not in MODE:
- problem_list.append(f"@{index} open: unknown mode {clean(repr(argument['mode']))}")
- if owner_problem(argument["owner"]):
- problem_list.append(f"@{index} open: owner is not an account name")
- if permission_value(argument["permission"]) is None or not isinstance(argument["permission"] ,int):
- problem_list.append(f"@{index} open: permission {clean(repr(argument['permission']))} is not valid")
- if name == "write":
- file = argument["file"]
- if not Symbol.is_data(file) or data["instruction_list"][file["symbol"]]["name"] != "open":
- problem_list.append(f"@{index} write: file is not the symbol of an open")
- if not isinstance(argument["content"] ,(bytes ,bytearray)):
- problem_list.append(f"@{index} write: content is not bytes")
- return problem_list
-
-def check_constraint(data ,constraint ,gate_invoking_account):
- "constraint is the guard, which holds the predicates; a predicate allows only by answering True"
- if constraint.is_allowed_account(gate_invoking_account) is not True:
- return [f"gate invoking account {clean(repr(gate_invoking_account))} is not allowed to give journals to this gate"]
- problem_list = []
- for index ,instruction in enumerate(data["instruction_list"]):
- name ,argument = instruction["name"] ,instruction["argument"]
- where = f"{argument.get('directory')}/{argument.get('name')}"
- if name == "open":
- if constraint.is_allowed_open(argument["directory"] ,argument["name"] ,argument["mode"] ,argument["owner"] ,argument["permission"]) is not True:
- problem_list.append(f"@{index} open: {where} in mode {argument['mode']}, owner {argument['owner']}, permission {argument['permission']:04o} is not allowed")
- elif name == "unlink":
- if constraint.is_allowed_unlink(argument["directory"] ,argument["name"]) is not True:
- problem_list.append(f"@{index} unlink: {where} is not allowed to be removed")
- return problem_list
-
-def check_validity(data):
- problem_list = []
- for index ,instruction in enumerate(data["instruction_list"]):
- if instruction["name"] == "open":
- owner = instruction["argument"]["owner"]
- try:
- pwd.getpwnam(owner)
- except KeyError:
- problem_list.append(f"@{index} open: no account is named {clean(owner)}")
- return problem_list
-
-def _regular_or_absent(dirfd ,name):
- "True when absent; False when a regular file; raises OSError otherwise"
- try:
- st = os.lstat(name ,dir_fd=dirfd)
- except FileNotFoundError:
- return True
- if not stat.S_ISREG(st.st_mode):
- raise OSError("it exists and is not a regular file")
- return False
-
-def check_filesystem(data):
- problem_list = []
- seen = set()
- for index ,instruction in enumerate(data["instruction_list"]):
- name ,argument = instruction["name"] ,instruction["argument"]
- if name not in ("open" ,"unlink"):
- continue
- directory ,file_name = argument["directory"] ,argument["name"]
- where = f"{directory}/{file_name}"
- if (directory ,file_name) in seen:
- problem_list.append(f"@{index} {name}: {where} is named by more than one instruction"); continue
- seen.add((directory ,file_name))
- if os.path.realpath(directory) != directory:
- problem_list.append(f"@{index} {name}: {directory} passes through a symbolic link"); continue
- try:
- dirfd = os.open(directory ,os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
- except OSError as error:
- problem_list.append(f"@{index} {name}: cannot open directory {directory} ({error.strerror})"); continue
- try:
- absent = _regular_or_absent(dirfd ,file_name)
- if name == "open" and argument["mode"] == "create" and not absent:
- problem_list.append(f"@{index} open: {where} exists, and the mode is create")
- except OSError as error:
- problem_list.append(f"@{index} {name}: {where}: {error}")
- finally:
- os.close(dirfd)
- return problem_list
-
-CHECK = (
- ("wellformed" ,lambda data ,constraint ,gate_invoking_account: check_wellformed(data))
- ,("constraint" ,check_constraint)
- ,("validity" ,lambda data ,constraint ,gate_invoking_account: check_validity(data))
- ,("filesystem" ,lambda data ,constraint ,gate_invoking_account: check_filesystem(data))
-)
-
-
-# --------------------------------------------------------------------------------
-# interpreter half: execution
-
-def _backup_name(dirfd ,name):
- stamp = time.strftime("%Y%m%dT%H%M%SZ" ,time.gmtime())
- candidate ,n = f"{name}.{stamp}" ,1
- while True:
- try:
- os.lstat(candidate ,dir_fd=dirfd)
- except FileNotFoundError:
- return candidate
- candidate ,n = f"{name}.{stamp}.{n}" ,n + 1
-
-def execute(data ,log ,constraint):
- """
- Execute the instructions in order, then put each opened file in place. Stops
- at the first error, removing every temporary file not yet put in place.
- log is given a tag and a message for each thing done. Returns 0, or 1 on
- an error. The file language needs nothing from its constraint module to execute.
- """
- opened = {} # symbol index -> {dirfd ,fd ,tmp ,argument}
- try:
- for index ,instruction in enumerate(data["instruction_list"]):
- name ,argument = instruction["name"] ,instruction["argument"]
- if name == "open":
- dirfd = os.open(argument["directory"] ,os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
- tmp = f".{argument['name']}.Man_In_Grey.{os.getpid()}.{index}"
- fd = os.open(tmp ,os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW ,0o600 ,dir_fd=dirfd)
- opened[index] = {"dirfd": dirfd ,"fd": fd ,"tmp": tmp ,"argument": argument}
- elif name == "write":
- fd ,view = opened[argument["file"]["symbol"]]["fd"] ,memoryview(argument["content"])
- while view:
- view = view[os.write(fd ,view):]
- elif name == "unlink":
- dirfd = os.open(argument["directory"] ,os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
- try:
- if not _regular_or_absent(dirfd ,argument["name"]):
- os.unlink(argument["name"] ,dir_fd=dirfd)
- os.fsync(dirfd)
- log("unlinked" ,f"{argument['directory']}/{argument['name']}")
- finally:
- os.close(dirfd)
-
- for index in sorted(opened):
- record = opened[index]
- argument ,dirfd ,fd ,tmp = record["argument"] ,record["dirfd"] ,record["fd"] ,record["tmp"]
- target = argument["name"]
- where = f"{argument['directory']}/{target}"
- account = pwd.getpwnam(argument["owner"])
- os.fsync(fd)
- # Only root can give a file away. Any other interpreter account makes
- # files it already owns, so the owner is changed only when it differs.
- st = os.fstat(fd)
- if (st.st_uid ,st.st_gid) != (account.pw_uid ,account.pw_gid):
- try:
- os.fchown(fd ,account.pw_uid ,account.pw_gid)
- except OSError as error:
- raise OSError(error.errno ,f"{where}: cannot give it the owner {argument['owner']} and that account's group: {error.strerror}")
- os.fchmod(fd ,argument["permission"])
- os.fsync(fd)
- os.close(fd); record["fd"] = None
- if argument["mode"] == "create":
- os.link(tmp ,target ,src_dir_fd=dirfd ,dst_dir_fd=dirfd ,follow_symlinks=False)
- os.unlink(tmp ,dir_fd=dirfd)
- log("created" ,where)
- else:
- kept = None
- if argument["mode"] == "replace_keep" and not _regular_or_absent(dirfd ,target):
- kept = _backup_name(dirfd ,target)
- os.link(target ,kept ,src_dir_fd=dirfd ,dst_dir_fd=dirfd ,follow_symlinks=False)
- os.rename(tmp ,target ,src_dir_fd=dirfd ,dst_dir_fd=dirfd)
- log("replaced" ,where + (f" (kept as {kept})" if kept else ""))
- record["tmp"] = None
- os.fsync(dirfd)
- return 0
- except OSError as error:
- log("execution_failed" ,str(error))
- return 1
- finally:
- for record in opened.values():
- if record.get("fd") is not None:
- os.close(record["fd"])
- if record.get("tmp") is not None:
- try:
- os.unlink(record["tmp"] ,dir_fd=record["dirfd"])
- except OSError:
- pass
- os.close(record["dirfd"])
check_passed for each check that found none.
This module is a library. An instruction language's guard program calls check()
-and writes the verdict; see guard_file.py and guard_emplace_bio.py.
+and writes the verdict; see guard_emplace_bio.py.
"""
import Journal
malformed journal never reaches execution.
This module is a library. An instruction language's interpreter program calls
-interpret(); see interpreter_file.py and interpreter_emplace_bio.py.
+interpret(); see interpreter_emplace_bio.py.
"""
import Journal
A composer is a Python file defining compose(prov ,journal_iface). prov
describes where the composer is; journal_iface places instructions of the
gate's instruction language. The orchestrator learns the language from the
-gate's flags, and loads the module named for it: the file language from
-FileLanguage.py, emplace_bio from EmplaceBioLanguage.py.
+gate's flags, and loads the module named for it: emplace_bio from
+EmplaceBioLanguage.py.
-Usage: Man_In_Grey <command> <path> [option ...]
+Usage: Man_In_Grey <command> <path> gate <file> [option ...]
help | -h print this message (also printed when given no arguments)
orchestrate <path> <path> is a composer file, or a composer directory; every
file in a composer directory is a composer, and they run
depth first, in order of name at each level
+ gate <file> the gate to invoke, which also sets the instruction
+ language the composers write in; required
Options:
- gate <file> the gate to invoke; by default
- ../Man_In_Grey_gate/Man_In_Grey_file_gate
print print the journal after composing
compose_only stop after composing; nothing reaches the gate
check_only have the gate run the guard and not the interpreter
# The promoted layout: this file in Man_In_Grey/, beside Man_In_Grey_gate/.
# The same layout is staged in developer/scratchpad/made/ and promoted to consumer/.
HERE_DPATH = Path(__file__).resolve().parent
-DEFAULT_GATE = HERE_DPATH.parent / "Man_In_Grey_gate" / "Man_In_Grey_file_gate"
FLAG = ("print" ,"compose_only" ,"check_only")
VALUE = ("gate" ,)
def orchestrate(path ,gate ,flag):
path = Path(path).absolute()
- gate_path = Path(gate).absolute() if gate else DEFAULT_GATE
+ if not gate:
+ print("error: orchestrate is given the gate to invoke, as gate <file>" ,file=sys.stderr)
+ return 2
+ gate_path = Path(gate).absolute()
try:
language = language_module(gate_flag(gate_path).get("language"))
prov_list = composer_list(path)
--- /dev/null
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+SmokeLanguage - the instruction language of the tester's smoke test.
+
+It exists to exercise the gate, the guard library and the interpreter
+library, and is not for use on a system: its constraints can say where a
+file goes, and with what mode, owner and permission, but not what the file
+says, and a configuration file's content is what its reader does. It is a
+constrained form of the POSIX file interface.
+
+ open(directory ,name ,mode ,owner ,permission) produces a symbol for the file
+ write(file ,content) appends content to the file
+ unlink(directory ,name) removes a file
+
+The modes of open:
+
+ create the file must not exist
+ replace an existing file is discarded
+ replace_keep an existing file is kept as <name>.YYYYMMDDTHHMMSSZ
+
+There is no close. Each opened file is written to a temporary file in its
+directory, and after the journal's last instruction every opened file is put in
+place, each in one step.
+
+The module has two halves. The composer half is JournalInterface, through
+which a composer places instructions; it checks every argument when the call is
+made, and raises JournalError on a wrong one. The interpreter half holds the
+checks the guard makes of a journal received as data, and the execution the
+interpreter carries out.
+
+The constraints are predicates written at the top of the guard,
+guard_smoke.py: is_allowed_account, is_allowed_open and is_allowed_unlink.
+"""
+
+import os ,pwd ,stat ,time
+from pathlib import Path
+
+from Journal import JournalError ,Symbol ,has_control ,clean
+
+NAME = "smoke"
+
+MODE = ("create" ,"replace" ,"replace_keep")
+
+ARGUMENT = {
+ "open": ("directory" ,"name" ,"mode" ,"owner" ,"permission")
+ ,"write": ("file" ,"content")
+ ,"unlink": ("directory" ,"name")
+}
+
+
+# --------------------------------------------------------------------------------
+# argument checks, shared by both halves
+
+def directory_problem(value):
+ if not isinstance(value ,str) or not value.startswith("/"):
+ return "directory is to be an absolute path"
+ if has_control(value):
+ return f"directory {clean(value)} holds a control character"
+ if os.path.normpath(value) != value:
+ return f"directory {value!r} is not in normal form; give {os.path.normpath(value)!r}"
+ return None
+
+def name_problem(value):
+ if not isinstance(value ,str) or not value or "/" in value or value in ("." ,".."):
+ return f"name {clean(repr(value))} is not a bare file name"
+ if has_control(value):
+ return f"name {clean(value)} holds a control character"
+ return None
+
+def owner_problem(value):
+ if not isinstance(value ,str) or not value or has_control(value):
+ return "owner is to be an account name"
+ return None
+
+def permission_value(value):
+ "an int 0..0o7777, or a string of 3 or 4 octal digits; None when neither"
+ if isinstance(value ,bool):
+ return None
+ if isinstance(value ,int):
+ return value if 0 <= value <= 0o7777 else None
+ if isinstance(value ,str) and len(value) in (3 ,4) and all(c in "01234567" for c in value):
+ return int(value ,8)
+ return None
+
+
+# --------------------------------------------------------------------------------
+# composer half
+
+class JournalInterface:
+ """
+ The journal interface of the smoke language. A composer is given one, and
+ places instructions with its three functions. open returns a Symbol: the file
+ the open instruction will produce when the journal is executed.
+ """
+
+ def __init__(self ,journal ,working_dpath):
+ self._journal = journal
+ self._working_dpath = Path(working_dpath)
+
+ def _directory(self ,directory):
+ if not isinstance(directory ,(str ,Path)) or str(directory) == "":
+ raise JournalError("directory is to be a path")
+ path = Path(directory)
+ if not path.is_absolute():
+ path = self._working_dpath / path
+ value = os.path.normpath(str(path))
+ problem = directory_problem(value)
+ if problem: raise JournalError(problem)
+ return value
+
+ def open(self ,directory ,name ,mode ,owner ,permission):
+ directory = self._directory(directory)
+ problem = name_problem(name)
+ if problem: raise JournalError(f"open: {problem}")
+ if mode not in MODE:
+ raise JournalError(f"open: mode {mode!r} is not one of {', '.join(MODE)}")
+ problem = owner_problem(owner)
+ if problem: raise JournalError(f"open: {problem}")
+ value = permission_value(permission)
+ if value is None:
+ raise JournalError(f"open: permission {permission!r} is not an integer 0 to 0o7777 or three or four octal digits")
+ return self._journal.place("open" ,{
+ "directory": directory ,"name": name ,"mode": mode ,"owner": owner ,"permission": value
+ })
+
+ def write(self ,file ,content):
+ if not isinstance(file ,Symbol) or self._journal.instruction_list[file.index].name != "open":
+ raise JournalError("write: file is to be the symbol an open returned")
+ if isinstance(content ,str):
+ content = content.encode("utf-8")
+ if not isinstance(content ,(bytes ,bytearray)):
+ raise JournalError("write: content is to be text or bytes")
+ self._journal.place("write" ,{"file": file ,"content": bytes(content)})
+
+ def unlink(self ,directory ,name):
+ directory = self._directory(directory)
+ problem = name_problem(name)
+ if problem: raise JournalError(f"unlink: {problem}")
+ self._journal.place("unlink" ,{"directory": directory ,"name": name})
+
+
+# --------------------------------------------------------------------------------
+# interpreter half: checks, each returning a list of problems
+
+def check_wellformed(data):
+ problem_list = []
+ for index ,instruction in enumerate(data["instruction_list"]):
+ name ,argument = instruction["name"] ,instruction["argument"]
+ if name not in ARGUMENT:
+ problem_list.append(f"@{index}: unknown instruction {clean(repr(name))}"); continue
+ if set(argument) != set(ARGUMENT[name]):
+ problem_list.append(f"@{index} {name}: arguments {clean(sorted(argument))}, expected {sorted(ARGUMENT[name])}"); continue
+ if name in ("open" ,"unlink"):
+ for problem in (directory_problem(argument["directory"]) ,name_problem(argument["name"])):
+ if problem: problem_list.append(f"@{index} {name}: {problem}")
+ if name == "open":
+ if argument["mode"] not in MODE:
+ problem_list.append(f"@{index} open: unknown mode {clean(repr(argument['mode']))}")
+ if owner_problem(argument["owner"]):
+ problem_list.append(f"@{index} open: owner is not an account name")
+ if permission_value(argument["permission"]) is None or not isinstance(argument["permission"] ,int):
+ problem_list.append(f"@{index} open: permission {clean(repr(argument['permission']))} is not valid")
+ if name == "write":
+ file = argument["file"]
+ if not Symbol.is_data(file) or data["instruction_list"][file["symbol"]]["name"] != "open":
+ problem_list.append(f"@{index} write: file is not the symbol of an open")
+ if not isinstance(argument["content"] ,(bytes ,bytearray)):
+ problem_list.append(f"@{index} write: content is not bytes")
+ return problem_list
+
+def check_constraint(data ,constraint ,gate_invoking_account):
+ "constraint is the guard, which holds the predicates; a predicate allows only by answering True"
+ if constraint.is_allowed_account(gate_invoking_account) is not True:
+ return [f"gate invoking account {clean(repr(gate_invoking_account))} is not allowed to give journals to this gate"]
+ problem_list = []
+ for index ,instruction in enumerate(data["instruction_list"]):
+ name ,argument = instruction["name"] ,instruction["argument"]
+ where = f"{argument.get('directory')}/{argument.get('name')}"
+ if name == "open":
+ if constraint.is_allowed_open(argument["directory"] ,argument["name"] ,argument["mode"] ,argument["owner"] ,argument["permission"]) is not True:
+ problem_list.append(f"@{index} open: {where} in mode {argument['mode']}, owner {argument['owner']}, permission {argument['permission']:04o} is not allowed")
+ elif name == "unlink":
+ if constraint.is_allowed_unlink(argument["directory"] ,argument["name"]) is not True:
+ problem_list.append(f"@{index} unlink: {where} is not allowed to be removed")
+ return problem_list
+
+def check_validity(data):
+ problem_list = []
+ for index ,instruction in enumerate(data["instruction_list"]):
+ if instruction["name"] == "open":
+ owner = instruction["argument"]["owner"]
+ try:
+ pwd.getpwnam(owner)
+ except KeyError:
+ problem_list.append(f"@{index} open: no account is named {clean(owner)}")
+ return problem_list
+
+def _regular_or_absent(dirfd ,name):
+ "True when absent; False when a regular file; raises OSError otherwise"
+ try:
+ st = os.lstat(name ,dir_fd=dirfd)
+ except FileNotFoundError:
+ return True
+ if not stat.S_ISREG(st.st_mode):
+ raise OSError("it exists and is not a regular file")
+ return False
+
+def check_filesystem(data):
+ problem_list = []
+ seen = set()
+ for index ,instruction in enumerate(data["instruction_list"]):
+ name ,argument = instruction["name"] ,instruction["argument"]
+ if name not in ("open" ,"unlink"):
+ continue
+ directory ,file_name = argument["directory"] ,argument["name"]
+ where = f"{directory}/{file_name}"
+ if (directory ,file_name) in seen:
+ problem_list.append(f"@{index} {name}: {where} is named by more than one instruction"); continue
+ seen.add((directory ,file_name))
+ if os.path.realpath(directory) != directory:
+ problem_list.append(f"@{index} {name}: {directory} passes through a symbolic link"); continue
+ try:
+ dirfd = os.open(directory ,os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
+ except OSError as error:
+ problem_list.append(f"@{index} {name}: cannot open directory {directory} ({error.strerror})"); continue
+ try:
+ absent = _regular_or_absent(dirfd ,file_name)
+ if name == "open" and argument["mode"] == "create" and not absent:
+ problem_list.append(f"@{index} open: {where} exists, and the mode is create")
+ except OSError as error:
+ problem_list.append(f"@{index} {name}: {where}: {error}")
+ finally:
+ os.close(dirfd)
+ return problem_list
+
+CHECK = (
+ ("wellformed" ,lambda data ,constraint ,gate_invoking_account: check_wellformed(data))
+ ,("constraint" ,check_constraint)
+ ,("validity" ,lambda data ,constraint ,gate_invoking_account: check_validity(data))
+ ,("filesystem" ,lambda data ,constraint ,gate_invoking_account: check_filesystem(data))
+)
+
+
+# --------------------------------------------------------------------------------
+# interpreter half: execution
+
+def _backup_name(dirfd ,name):
+ stamp = time.strftime("%Y%m%dT%H%M%SZ" ,time.gmtime())
+ candidate ,n = f"{name}.{stamp}" ,1
+ while True:
+ try:
+ os.lstat(candidate ,dir_fd=dirfd)
+ except FileNotFoundError:
+ return candidate
+ candidate ,n = f"{name}.{stamp}.{n}" ,n + 1
+
+def execute(data ,log ,constraint):
+ """
+ Execute the instructions in order, then put each opened file in place. Stops
+ at the first error, removing every temporary file not yet put in place.
+ log is given a tag and a message for each thing done. Returns 0, or 1 on
+ an error. The smoke language needs nothing from its guard's constraints to execute.
+ """
+ opened = {} # symbol index -> {dirfd ,fd ,tmp ,argument}
+ try:
+ for index ,instruction in enumerate(data["instruction_list"]):
+ name ,argument = instruction["name"] ,instruction["argument"]
+ if name == "open":
+ dirfd = os.open(argument["directory"] ,os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
+ tmp = f".{argument['name']}.Man_In_Grey.{os.getpid()}.{index}"
+ fd = os.open(tmp ,os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW ,0o600 ,dir_fd=dirfd)
+ opened[index] = {"dirfd": dirfd ,"fd": fd ,"tmp": tmp ,"argument": argument}
+ elif name == "write":
+ fd ,view = opened[argument["file"]["symbol"]]["fd"] ,memoryview(argument["content"])
+ while view:
+ view = view[os.write(fd ,view):]
+ elif name == "unlink":
+ dirfd = os.open(argument["directory"] ,os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
+ try:
+ if not _regular_or_absent(dirfd ,argument["name"]):
+ os.unlink(argument["name"] ,dir_fd=dirfd)
+ os.fsync(dirfd)
+ log("unlinked" ,f"{argument['directory']}/{argument['name']}")
+ finally:
+ os.close(dirfd)
+
+ for index in sorted(opened):
+ record = opened[index]
+ argument ,dirfd ,fd ,tmp = record["argument"] ,record["dirfd"] ,record["fd"] ,record["tmp"]
+ target = argument["name"]
+ where = f"{argument['directory']}/{target}"
+ account = pwd.getpwnam(argument["owner"])
+ os.fsync(fd)
+ # Only root can give a file away. Any other interpreter account makes
+ # files it already owns, so the owner is changed only when it differs.
+ st = os.fstat(fd)
+ if (st.st_uid ,st.st_gid) != (account.pw_uid ,account.pw_gid):
+ try:
+ os.fchown(fd ,account.pw_uid ,account.pw_gid)
+ except OSError as error:
+ raise OSError(error.errno ,f"{where}: cannot give it the owner {argument['owner']} and that account's group: {error.strerror}")
+ os.fchmod(fd ,argument["permission"])
+ os.fsync(fd)
+ os.close(fd); record["fd"] = None
+ if argument["mode"] == "create":
+ os.link(tmp ,target ,src_dir_fd=dirfd ,dst_dir_fd=dirfd ,follow_symlinks=False)
+ os.unlink(tmp ,dir_fd=dirfd)
+ log("created" ,where)
+ else:
+ kept = None
+ if argument["mode"] == "replace_keep" and not _regular_or_absent(dirfd ,target):
+ kept = _backup_name(dirfd ,target)
+ os.link(target ,kept ,src_dir_fd=dirfd ,dst_dir_fd=dirfd ,follow_symlinks=False)
+ os.rename(tmp ,target ,src_dir_fd=dirfd ,dst_dir_fd=dirfd)
+ log("replaced" ,where + (f" (kept as {kept})" if kept else ""))
+ record["tmp"] = None
+ os.fsync(dirfd)
+ return 0
+ except OSError as error:
+ log("execution_failed" ,str(error))
+ return 1
+ finally:
+ for record in opened.values():
+ if record.get("fd") is not None:
+ os.close(record["fd"])
+ if record.get("tmp") is not None:
+ try:
+ os.unlink(record["tmp"] ,dir_fd=record["dirfd"])
+ except OSError:
+ pass
+ os.close(record["dirfd"])
+++ /dev/null
-#!/usr/bin/env -S python3 -B
-# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-"""
-guard_file - the guard of the file instruction language.
-
-Started by the gate Man_In_Grey_file_gate, with the gate's real and
-effective ids, and with the journal on standard input. It checks the
-journal, and says what it found on standard error, one line per finding,
-as <tag>: <message>; the first two lines are name: and version:.
-
-When every check passes, it writes one line to standard output,
-
- guard file check passed
-
-and exits 0. That line and that status together are the only verdict the
-gate admits a journal on.
-
-Its constraints are the predicates at the top of this file. The
-interpreter imports this file for what execution needs from them.
-
-Before it imports anything, it checks that its modules and their directory
-are owned by root or by the effective uid's account, and writable by no
-other account. A module runs code when it is imported.
-
-Usage: guard_file <command>
- help | -h print this message (also printed when given no arguments)
- check check the journal on standard input
-
-Exit status: 0 when every check passed, 1 when a check refused the journal,
-2 when the journal could not be checked.
-"""
-
-# ------------------------------------------------------------------------------
-# The constraints: the predicates this guard checks a journal with. They are
-# written by whoever installs the gate, between the two marks below, and
-# install_gate can put them in from a file and keeps them when it installs
-# the guard again. A predicate allows only by answering True. As promoted
-# with Man_In_Grey, is_allowed_account refuses every account.
-#
-# They are called only after the modules below are checked and imported, so
-# they can call Constraint, the library of predicates that recur, as
-# Constraint.is_group_member(user_name ,"bio").
-
-# constraints begin
-
-def is_allowed_account(user_name):
- "True when the account, the gate's real uid, can give journals to the gate"
- return False
-
-def is_allowed_open(directory ,name ,mode ,owner ,permission):
- "True when the file can be opened in this mode, to be given this owner and permission"
- return False
-
-def is_allowed_unlink(directory ,name):
- "True when the file can be removed"
- return False
-
-# constraints end
-# ------------------------------------------------------------------------------
-
-
-import sys ,os
-sys.dont_write_bytecode = True
-
-import pwd ,stat
-from pathlib import Path
-
-LANGUAGE = "file"
-VERSION = "3"
-
-HERE_DPATH = Path(__file__).resolve().parent
-MODULE = ("Journal.py" ,"Guard.py" ,"Constraint.py" ,"FileLanguage.py" ,"guard_file.py")
-
-
-def trusted_owner_problem(path):
- "None when path is owned by root or the effective uid's account and writable by no other account"
- try:
- st = os.lstat(path)
- except OSError as error:
- return f"{path}: {error.strerror}"
- if st.st_uid not in (0 ,os.geteuid()):
- return f"{path} is owned by uid {st.st_uid}, neither root nor the effective uid's account"
- if st.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
- return f"{path} is writable by group or other accounts"
- return None
-
-def say(tag ,message):
- print(f"{tag}: {message}" ,file=sys.stderr)
-
-if __name__ == "__main__" and sys.argv[1:] == ["check"]:
- say("name" ,LANGUAGE)
- say("version" ,VERSION)
-
-for each in [HERE_DPATH] + [HERE_DPATH / m for m in MODULE]:
- problem = trusted_owner_problem(each)
- if problem:
- say("refused_to_start" ,problem)
- sys.exit(2)
-
-try:
- import Journal
- import Guard
- import Constraint
- import FileLanguage as Language
-except ImportError as error:
- say("cannot_start" ,str(error))
- sys.exit(2)
-
-
-def report(tag ,message):
- say(tag ,Journal.clean(message))
-
-def check():
- report("constraints" ,str(Path(__file__).resolve()))
- gate_invoking_account = pwd.getpwuid(os.getuid()).pw_name
- status = Guard.check(sys.stdin.buffer.read() ,Language ,sys.modules[__name__] ,gate_invoking_account ,report)
- if status == 0:
- sys.stdout.write(f"guard {LANGUAGE} check passed\n")
- return status
-
-
-# --------------------------------------------------------------------------------
-# command line
-
-def print_help():
- print(__doc__.strip())
- return 0
-
-def CLI(argv=None):
- argv = sys.argv[1:] if argv is None else argv
- if not argv or argv[0] in ("help" ,"-h" ,"--help"): return print_help()
- if argv != ["check"]:
- say("usage" ,f"unknown command {Journal.clean(repr(' '.join(argv)))}; run guard_file with no arguments for its usage")
- return 2
- return check()
-
-
-if __name__ == "__main__":
- try:
- sys.exit(CLI())
- except Exception as error:
- say("failed" ,Journal.clean(f"{type(error).__name__}: {error}"))
- sys.exit(2)
--- /dev/null
+#!/usr/bin/env -S python3 -B
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+guard_smoke - the guard of the smoke instruction language.
+
+Started by the gate Man_In_Grey_smoke_gate, with the gate's real and
+effective ids, and with the journal on standard input. It checks the
+journal, and says what it found on standard error, one line per finding,
+as <tag>: <message>; the first two lines are name: and version:.
+
+When every check passes, it writes one line to standard output,
+
+ guard smoke check passed
+
+and exits 0. That line and that status together are the only verdict the
+gate admits a journal on.
+
+Its constraints are the predicates at the top of this file. The
+interpreter imports this file for what execution needs from them.
+
+Before it imports anything, it checks that its modules and their directory
+are owned by root or by the effective uid's account, and writable by no
+other account. A module runs code when it is imported.
+
+Usage: guard_smoke <command>
+ help | -h print this message (also printed when given no arguments)
+ check check the journal on standard input
+
+Exit status: 0 when every check passed, 1 when a check refused the journal,
+2 when the journal could not be checked.
+"""
+
+# ------------------------------------------------------------------------------
+# The constraints: the predicates this guard checks a journal with. They are
+# written by whoever installs the gate, between the two marks below, and
+# install_gate can put them in from a file and keeps them when it installs
+# the guard again. A predicate allows only by answering True. As promoted
+# with Man_In_Grey, is_allowed_account refuses every account.
+#
+# They are called only after the modules below are checked and imported, so
+# they can call Constraint, the library of predicates that recur, as
+# Constraint.is_group_member(user_name ,"bio").
+
+# constraints begin
+
+def is_allowed_account(user_name):
+ "True when the account, the gate's real uid, can give journals to the gate"
+ return False
+
+def is_allowed_open(directory ,name ,mode ,owner ,permission):
+ "True when the file can be opened in this mode, to be given this owner and permission"
+ return False
+
+def is_allowed_unlink(directory ,name):
+ "True when the file can be removed"
+ return False
+
+# constraints end
+# ------------------------------------------------------------------------------
+
+
+import sys ,os
+sys.dont_write_bytecode = True
+
+import pwd ,stat
+from pathlib import Path
+
+LANGUAGE = "smoke"
+VERSION = "3"
+
+HERE_DPATH = Path(__file__).resolve().parent
+MODULE = ("Journal.py" ,"Guard.py" ,"Constraint.py" ,"SmokeLanguage.py" ,"guard_smoke.py")
+
+
+def trusted_owner_problem(path):
+ "None when path is owned by root or the effective uid's account and writable by no other account"
+ try:
+ st = os.lstat(path)
+ except OSError as error:
+ return f"{path}: {error.strerror}"
+ if st.st_uid not in (0 ,os.geteuid()):
+ return f"{path} is owned by uid {st.st_uid}, neither root nor the effective uid's account"
+ if st.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
+ return f"{path} is writable by group or other accounts"
+ return None
+
+def say(tag ,message):
+ print(f"{tag}: {message}" ,file=sys.stderr)
+
+if __name__ == "__main__" and sys.argv[1:] == ["check"]:
+ say("name" ,LANGUAGE)
+ say("version" ,VERSION)
+
+for each in [HERE_DPATH] + [HERE_DPATH / m for m in MODULE]:
+ problem = trusted_owner_problem(each)
+ if problem:
+ say("refused_to_start" ,problem)
+ sys.exit(2)
+
+try:
+ import Journal
+ import Guard
+ import Constraint
+ import SmokeLanguage as Language
+except ImportError as error:
+ say("cannot_start" ,str(error))
+ sys.exit(2)
+
+
+def report(tag ,message):
+ say(tag ,Journal.clean(message))
+
+def check():
+ report("constraints" ,str(Path(__file__).resolve()))
+ gate_invoking_account = pwd.getpwuid(os.getuid()).pw_name
+ status = Guard.check(sys.stdin.buffer.read() ,Language ,sys.modules[__name__] ,gate_invoking_account ,report)
+ if status == 0:
+ sys.stdout.write(f"guard {LANGUAGE} check passed\n")
+ return status
+
+
+# --------------------------------------------------------------------------------
+# command line
+
+def print_help():
+ print(__doc__.strip())
+ return 0
+
+def CLI(argv=None):
+ argv = sys.argv[1:] if argv is None else argv
+ if not argv or argv[0] in ("help" ,"-h" ,"--help"): return print_help()
+ if argv != ["check"]:
+ say("usage" ,f"unknown command {Journal.clean(repr(' '.join(argv)))}; run guard_smoke with no arguments for its usage")
+ return 2
+ return check()
+
+
+if __name__ == "__main__":
+ try:
+ sys.exit(CLI())
+ except Exception as error:
+ say("failed" ,Journal.clean(f"{type(error).__name__}: {error}"))
+ sys.exit(2)
+++ /dev/null
-#!/usr/bin/env -S python3 -B
-# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-"""
-interpreter_file - the interpreter of the file instruction language.
-
-Started by the gate Man_In_Grey_file_gate, with the gate's real and
-effective ids, and with the journal on standard input, once the guard has
-passed the journal. It executes the journal's instructions, and writes to
-standard output, as <tag>: <message>, a line for each lasting change it
-makes, such as a file put in place, and none for an instruction as such.
-The gate logs the journal's course itself.
-
-It checks again that the journal is well formed before executing it. It
-does not check the constraints; that is the guard's work, and the gate
-starts the interpreter only on the guard's verdict. It imports the guard,
-guard_file.py, for what execution needs from the constraints at its top.
-
-Before it imports anything, it checks that its modules and their directory
-are owned by root or by the effective uid's account, and writable by no
-other account.
-
-Usage: interpreter_file <command>
- help | -h print this message (also printed when given no arguments)
- execute execute the journal on standard input
-
-Exit status: 0 when the journal was executed, 1 when execution failed,
-2 when the journal could not be read or is not well formed.
-"""
-
-import sys ,os
-sys.dont_write_bytecode = True
-
-import stat
-from pathlib import Path
-
-HERE_DPATH = Path(__file__).resolve().parent
-MODULE = ("Journal.py" ,"Interpreter.py" ,"Constraint.py" ,"FileLanguage.py" ,"guard_file.py" ,"interpreter_file.py")
-
-
-def trusted_owner_problem(path):
- "None when path is owned by root or the effective uid's account and writable by no other account"
- try:
- st = os.lstat(path)
- except OSError as error:
- return f"{path}: {error.strerror}"
- if st.st_uid not in (0 ,os.geteuid()):
- return f"{path} is owned by uid {st.st_uid}, neither root nor the effective uid's account"
- if st.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
- return f"{path} is writable by group or other accounts"
- return None
-
-def say(tag ,message):
- print(f"{tag}: {message}" ,file=sys.stderr)
-
-for each in [HERE_DPATH] + [HERE_DPATH / m for m in MODULE]:
- problem = trusted_owner_problem(each)
- if problem:
- say("refused_to_start" ,problem)
- sys.exit(2)
-
-try:
- import Journal
- import Interpreter
- import FileLanguage as Language
- import guard_file as Guard_program
-except ImportError as error:
- say("cannot_start" ,str(error))
- sys.exit(2)
-
-
-def log(tag ,message):
- print(f"{tag}: {Journal.clean(message)}" ,flush=True)
-
-def execute():
- return Interpreter.interpret(sys.stdin.buffer.read() ,Language ,log ,Guard_program)
-
-
-# --------------------------------------------------------------------------------
-# command line
-
-def print_help():
- print(__doc__.strip())
- return 0
-
-def CLI(argv=None):
- argv = sys.argv[1:] if argv is None else argv
- if not argv or argv[0] in ("help" ,"-h" ,"--help"): return print_help()
- if argv != ["execute"]:
- say("usage" ,f"unknown command {Journal.clean(repr(' '.join(argv)))}; run interpreter_file with no arguments for its usage")
- return 2
- return execute()
-
-
-if __name__ == "__main__":
- try:
- sys.exit(CLI())
- except Exception as error:
- say("failed" ,Journal.clean(f"{type(error).__name__}: {error}"))
- sys.exit(2)
--- /dev/null
+#!/usr/bin/env -S python3 -B
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+interpreter_smoke - the interpreter of the smoke instruction language.
+
+Started by the gate Man_In_Grey_smoke_gate, with the gate's real and
+effective ids, and with the journal on standard input, once the guard has
+passed the journal. It executes the journal's instructions, and writes to
+standard output, as <tag>: <message>, a line for each lasting change it
+makes, such as a file put in place, and none for an instruction as such.
+The gate logs the journal's course itself.
+
+It checks again that the journal is well formed before executing it. It
+does not check the constraints; that is the guard's work, and the gate
+starts the interpreter only on the guard's verdict. It imports the guard,
+guard_smoke.py, for what execution needs from the constraints at its top.
+
+Before it imports anything, it checks that its modules and their directory
+are owned by root or by the effective uid's account, and writable by no
+other account.
+
+Usage: interpreter_smoke <command>
+ help | -h print this message (also printed when given no arguments)
+ execute execute the journal on standard input
+
+Exit status: 0 when the journal was executed, 1 when execution failed,
+2 when the journal could not be read or is not well formed.
+"""
+
+import sys ,os
+sys.dont_write_bytecode = True
+
+import stat
+from pathlib import Path
+
+HERE_DPATH = Path(__file__).resolve().parent
+MODULE = ("Journal.py" ,"Interpreter.py" ,"Constraint.py" ,"SmokeLanguage.py" ,"guard_smoke.py" ,"interpreter_smoke.py")
+
+
+def trusted_owner_problem(path):
+ "None when path is owned by root or the effective uid's account and writable by no other account"
+ try:
+ st = os.lstat(path)
+ except OSError as error:
+ return f"{path}: {error.strerror}"
+ if st.st_uid not in (0 ,os.geteuid()):
+ return f"{path} is owned by uid {st.st_uid}, neither root nor the effective uid's account"
+ if st.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
+ return f"{path} is writable by group or other accounts"
+ return None
+
+def say(tag ,message):
+ print(f"{tag}: {message}" ,file=sys.stderr)
+
+for each in [HERE_DPATH] + [HERE_DPATH / m for m in MODULE]:
+ problem = trusted_owner_problem(each)
+ if problem:
+ say("refused_to_start" ,problem)
+ sys.exit(2)
+
+try:
+ import Journal
+ import Interpreter
+ import SmokeLanguage as Language
+ import guard_smoke as Guard_program
+except ImportError as error:
+ say("cannot_start" ,str(error))
+ sys.exit(2)
+
+
+def log(tag ,message):
+ print(f"{tag}: {Journal.clean(message)}" ,flush=True)
+
+def execute():
+ return Interpreter.interpret(sys.stdin.buffer.read() ,Language ,log ,Guard_program)
+
+
+# --------------------------------------------------------------------------------
+# command line
+
+def print_help():
+ print(__doc__.strip())
+ return 0
+
+def CLI(argv=None):
+ argv = sys.argv[1:] if argv is None else argv
+ if not argv or argv[0] in ("help" ,"-h" ,"--help"): return print_help()
+ if argv != ["execute"]:
+ say("usage" ,f"unknown command {Journal.clean(repr(' '.join(argv)))}; run interpreter_smoke with no arguments for its usage")
+ return 2
+ return execute()
+
+
+if __name__ == "__main__":
+ try:
+ sys.exit(CLI())
+ except Exception as error:
+ say("failed" ,Journal.clean(f"{type(error).__name__}: {error}"))
+ sys.exit(2)
+++ /dev/null
-/*
- Man_In_Grey_file_gate - the gate of the file instruction language: the general
- gate with the language name and the guard's and interpreter's paths fixed.
-*/
-#include "Gate.lib.c"
-
-int main(int argc ,char **argv){
- return Man_In_Grey_gate·Gate·run("file" ,"../Man_In_Grey/guard_file.py" ,"../Man_In_Grey/interpreter_file.py" ,argc ,argv);
-}
--- /dev/null
+/*
+ Man_In_Grey_smoke_gate - the gate of the smoke test's instruction language: the general
+ gate with the language name and the guard's and interpreter's paths fixed.
+*/
+#include "Gate.lib.c"
+
+int main(int argc ,char **argv){
+ return Man_In_Grey_gate·Gate·run("smoke" ,"../Man_In_Grey/guard_smoke.py" ,"../Man_In_Grey/interpreter_smoke.py" ,argc ,argv);
+}
<p>
This document is both the design of Man_In_Grey and the user manual, as Man_In_Grey is a programmer's tool.
</p>
+ <p>
+ All programs in Man_In_Grey are written with lead-in 'CLI' functions whose only job is to parse arguments and to format return values. Each CLI will call one or more functions to do the actual work. In this manner programs can be included as modules so as to reuse their helper functions, or to gain access to the main work functions.
+ </p>
+
</RT·section>
<RT·section id="sec-term">
<RT·section id="sec-symbol">
<RT·name>Symbol</RT·name>
<p>
- A composer writes instructions, but it does not run them. When an instruction would produce a result when run, as for example the file <RT·code>open</RT·code> instruction, which returns a file handle, the result does not exist yet at the time the composer runs. Hence, when an instruction is entered into the journal via the journal interface, the interface returns a <RT·term>symbol</RT·term> placeholder. The symbol is then used as an argument by the composer when it enters instructions that are given said result as an argument. In the current implementation a symbol is simply the index into the journal that locates an instruction. Thus, with the current implementation each instruction can have at most one result.
+ A composer writes instructions, but it does not run them. When an instruction would produce a result when run, as for example an instruction that makes an account and returns its uid, the result does not exist yet at the time the composer runs. Hence, when an instruction is entered into the journal via the journal interface, the interface returns a <RT·term>symbol</RT·term> placeholder. The symbol is then used as an argument by the composer when it enters instructions that are given said result as an argument. In the current implementation a symbol is simply the index into the journal that locates an instruction. Thus, with the current implementation each instruction can have at most one result.
</p>
<RT·code>
- file = journal_iface.open("/etc/unbound" ,"unbound-US.conf"
- ,"replace_keep" ,"root" ,0o644)
- journal_iface.write(file ,"server:\n verbosity: 1\n")
+ account = journal_iface.make_account("Thomas·www")
+ journal_iface.add_to_group(account ,"http")
</RT·code>
<p>
- In this example <RT·code>file</RT·code> is a result symbol. The value of <RT·code>file</RT·code> will be 0, i.e. the index into the journal of the <RT·code>open</RT·code> instruction.
+ In this example <RT·code>account</RT·code> is a result symbol. The value of <RT·code>account</RT·code> will be 0, i.e. the index into the journal of the <RT·code>make_account</RT·code> instruction. The two instructions are of a language an application that manages accounts would define; the example language in <a href="#sec-example"><RT·Counter·read snapshot="sec-example" key="counter count"></RT·Counter·read></a> has no instruction that produces a result.
</p>
</RT·section>
The gate is compiled because Linux ignores the setuid bit on a script started with <RT·code>#!</RT·code>. Given no arguments, the gate prints its usage. Its command <RT·code>flags</RT·code> reports the gate's real and effective uid and gid with their names, whether they differ, the name of the gate's instruction language, and the paths of the guard and the interpreter.
</p>
<RT·code>
- 2026-11-06 07:55:01 Z [:] Thomas·developer@IdealX
- §/home/Thomas·developer/project/Man_In_Grey/consumer§
- > ./Man_In_Grey_gate/Man_In_Grey_file_gate flags
- real.uid=2005
- real.user_name=Thomas·developer
- real.gid=2005
- real.group_name=Thomas·developer
- effective.uid=2005
- effective.user_name=Thomas·developer
- effective.gid=2005
- effective.group_name=Thomas·developer
- uid_differ=0
- gid_differ=0
- language=file
- guard.path=/home/Thomas·developer/project/Man_In_Grey/consumer/Man_In_Grey/guard_file.py
- interpreter.path=/home/Thomas·developer/project/Man_In_Grey/consumer/Man_In_Grey/interpreter_file.py
+ > /usr/local/lib/Man_In_Grey/Man_In_Grey_gate/Man_In_Grey_emplace_bio_gate flags
+ real.uid=2006
+ real.user_name=Thomas
+ real.gid=2006
+ real.group_name=Thomas
+ effective.uid=996
+ effective.user_name=http
+ effective.gid=995
+ effective.group_name=http
+ uid_differ=1
+ gid_differ=1
+ language=emplace_bio
+ guard.path=/usr/local/lib/Man_In_Grey/Man_In_Grey/guard_emplace_bio.py
+ interpreter.path=/usr/local/lib/Man_In_Grey/Man_In_Grey/interpreter_emplace_bio.py
</RT·code>
+ <p>
+ Here Thomas asks the example language's gate, <a href="#sec-example"><RT·Counter·read snapshot="sec-example" key="counter count"></RT·Counter·read></a>, installed setuid to the web server's account <RT·code>http</RT·code>: the real ids are his, and the effective ids are <RT·code>http</RT·code>'s.
+ </p>
<p>
Th <RT·code>admit</RT·code> command takes the journal on standard input, with the option <RT·code>check_only</RT·code>. When the gate is asked to <RT·code>admit</RT·code> a journal, it:
<RT·section>
<RT·name>One gate per instruction language</RT·name>
<p>
- Man_In_Grey's general gate is the library module <RT·code>Gate.lib.c</RT·code>, whose function <RT·code>Man_In_Grey_gate·Gate·run</RT·code> takes the name of the instruction language, the guard's path and the interpreter's path, and the command line. An instruction language's gate is a <RT·code>*.CLI.c</RT·code> of three lines that calls it with the three fixed. The gate of the file language:
+ Man_In_Grey's general gate is the library module <RT·code>Gate.lib.c</RT·code>, whose function <RT·code>Man_In_Grey_gate·Gate·run</RT·code> takes the name of the instruction language, the guard's path and the interpreter's path, and the command line. An instruction language's gate is a <RT·code>*.CLI.c</RT·code> of three lines that calls it with the three fixed. The gate of the example language:
</p>
<RT·code>
#include "Gate.lib.c"
int main(int argc ,char **argv){
- return Man_In_Grey_gate·Gate·run("file" ,"../Man_In_Grey/guard_file.py"
- ,"../Man_In_Grey/interpreter_file.py" ,argc ,argv);
+ return Man_In_Grey_gate·Gate·run("emplace_bio" ,"../Man_In_Grey/guard_emplace_bio.py"
+ ,"../Man_In_Grey/interpreter_emplace_bio.py" ,argc ,argv);
}
</RT·code>
<p>
- The general gate is curried, with its first three arguments supplied when the gate is compiled. Whoever invokes a gate can choose neither program it runs; if he could, he could give it a guard that approves everything. The language name holds no space and no control character, and the gate refuses to run when it does. Man_In_Grey has two gates: the file language's, above, and <RT·code>Man_In_Grey_emplace_bio_gate</RT·code>, the gate of the example language in <a href="#sec-example"><RT·Counter·read snapshot="sec-example" key="counter count"></RT·Counter·read></a>.
+ The general gate is curried, with its first three arguments supplied when the gate is compiled. Whoever invokes a gate can choose neither program it runs; if he could, he could give it a guard that approves everything. The language name holds no space and no control character, and the gate refuses to run when it does.
</p>
</RT·section>
<RT·section>
<RT·name>Output rule</RT·name>
<p>
- Every line the guard and the interpreter write is copied to the person, marked <RT·code>guard:</RT·code> or <RT·code>interpreter:</RT·code>, and to the system log under the name <RT·code>Man_In_Grey_gate</RT·code>, beside the gate's own lines, marked <RT·code>gate:</RT·code>. The gate logs each journal's course itself, whatever the guard and the interpreter write: <RT·code>received</RT·code>, with account A, account B and the journal's size; then <RT·code>approved</RT·code> or <RT·code>refused</RT·code>; then <RT·code>interpreting</RT·code>; then <RT·code>interpreted</RT·code> or <RT·code>failed</RT·code>. A line breaks the gate's rules when it is not UTF-8, or holds a control character (C0, DEL or C1), or is longer than 4096 bytes, or is one line more than 2000; the gate then stops the program and refuses the journal. Unicode is otherwise welcome. The guard writes any control character it quotes as <RT·code>\u{XXXX}</RT·code>, and the wellformed check refuses a directory, file name or owner that holds one.
+ Every line the guard and the interpreter write is copied to the person, marked <RT·code>guard:</RT·code> or <RT·code>interpreter:</RT·code>, and to the system log under the name <RT·code>Man_In_Grey_gate</RT·code>, beside the gate's own lines, marked <RT·code>gate:</RT·code>. The gate logs each journal's course itself, whatever the guard and the interpreter write: <RT·code>received</RT·code>, with account A, account B and the journal's size; then <RT·code>approved</RT·code> or <RT·code>refused</RT·code>; then <RT·code>interpreting</RT·code>; then <RT·code>interpreted</RT·code> or <RT·code>failed</RT·code>. A line breaks the gate's rules when it is not UTF-8, or holds a control character (C0, DEL or C1), or is longer than 4096 bytes, or is one line more than 2000; the gate then stops the program and refuses the journal. Unicode is otherwise welcome. The guard writes any control character it quotes as <RT·code>\u{XXXX}</RT·code>, and the example language's wellformed check refuses any text that holds one.
</p>
</RT·section>
<RT·section>
A setuid gate owned by root gives the interpreter every one of root's rights. Root's rights are a set of about forty separate capabilities, among them <RT·code>CAP_DAC_OVERRIDE</RT·code>, to ignore file permissions; <RT·code>CAP_CHOWN</RT·code>, to change owners; <RT·code>CAP_SYS_ADMIN</RT·code>, to mount; and <RT·code>CAP_SETUID</RT·code>, to become another uid. The design is to give each such gate only the capabilities its instruction language needs.
</p>
<p>
- <RT·code>setcap</RT·code> stores chosen capabilities in a program file's extended attributes. A process running that file holds them while its real and effective uid stay account A's. Capabilities are normally lost when a program <RT·code>exec</RT·code>s one whose file holds none, as the gate does when it starts Python; the gate keeps them by raising them as ambient capabilities first, which survive the <RT·code>exec</RT·code>. A gate for the file language would then be able to write files and change owners, and nothing else. The gain is uneven: <RT·code>CAP_SYS_ADMIN</RT·code>, needed for mounting, is nearly root by itself.
+ <RT·code>setcap</RT·code> stores chosen capabilities in a program file's extended attributes. A process running that file holds them while its real and effective uid stay account A's. Capabilities are normally lost when a program <RT·code>exec</RT·code>s one whose file holds none, as the gate does when it starts Python; the gate keeps them by raising them as ambient capabilities first, which survive the <RT·code>exec</RT·code>. A gate for a language that places files owned by other accounts would then be able to write files and change owners, and nothing else. The gain is uneven: <RT·code>CAP_SYS_ADMIN</RT·code>, needed for mounting, is nearly root by itself.
</p>
</RT·section>
<RT·section>
Two checks are needed by nearly every guard, whatever its instruction language. The first is that the journal is well formed: that it decodes, names only instructions of its language, and gives each instruction the arguments it takes, of the right kinds. This check carries knowledge of the instruction syntax, so it belongs to the instruction language. The second is who account A is. The gate's real uid is account A's, and its real gid and supplementary groups are those it inherited from the invoking program, since the setuid and setgid bits change only the effective ids. All three pass to the guard, which reads them from the kernel, so account A cannot misstate them. A guard can therefore allow a journal from the accounts it names, or from the members of a group, as its constraints decide.
</p>
<p>
- The other checks a guard makes belong to its instruction language. The file language's four checks are in <a href="#sec-file-language"><RT·Counter·read snapshot="sec-file-language" key="counter count"></RT·Counter·read></a>.
+ The other checks a guard makes belong to its instruction language. The example language's four checks are in <a href="#sec-example"><RT·Counter·read snapshot="sec-example" key="counter count"></RT·Counter·read></a>.
</p>
<p>
A guard's constraints are code. The guard is a Python program, written for one application, so its constraints are Python functions written at the top of the guard itself, where they go with it, and no language for expressing constraints is needed. Each is a <RT·term>constraint predicate</RT·term>, named in the manner of <RT·code>is_allowed_account(user_name)</RT·code> or <RT·code>is_allowed_open(directory ,name ,mode ,owner ,permission)</RT·code>, and the guard refuses a journal when one does not answer <RT·code>True</RT·code>. A predicate can compare with a list, ask whether an account is in a group, or anything else Python can say. The predicates sit between the lines <RT·code># constraints begin</RT·code> and <RT·code># constraints end</RT·code>, so that <RT·code>install_gate</RT·code> can put in an administrator's and keep them when the guard is installed again. As promoted with Man_In_Grey, they refuse every account.
<RT·section id="sec-interpreter">
<RT·name>Interpreter</RT·name>
<p>
- The gate starts the interpreter only after the guard has approved the journal, with the effective uid of account B and the same journal on its standard input. The interpreter does not check the constraints; that is the guard's work. It imports the guard program only for what execution needs from its constraints, as the example language's interpreter calls the guard's <RT·code>page_directory()</RT·code> for the directory its pages go in. It decodes the journal, checks once more that the journal is well formed, and then executes the instructions in order.
+ The gate starts the interpreter only after the guard has approved the journal, with the effective uid of account B and the same journal on its standard input. Generally the interpreter does not check the constraints as that is the guard's work, but some checks will naturally occur as part of interpretation. Where useful the guard program module can be included so as to pull in the helper functions.
</p>
<p>
- The interpreter writes to standard output, as <RT·code><tag>: <message></RT·code>, the highlights of what it does, as its developer chooses them, and the gate copies each line to the person and to the system log by the output rule. A line for each instruction would be impractical: a language that builds a file from many <RT·code>write</RT·code>s would log each, and a line that echoed an instruction's arguments would quote content, which can be long and does not belong in a log. The interpreters of Man_In_Grey write a line for each lasting change, such as a file put in place or removed, and none for an instruction as such. The gate logs the journal's course itself, so the log shows each journal received, approved and interpreted whatever an interpreter writes. The interpreter's exit status is 0 when it finished the journal, 1 when it stopped partway, and 2 when the journal could not be read or is not well formed.
+ The gate logs when a journal is received, approved, and interpreted. The interpreter's exit status is 0 when it finished the journal, 1 when it stopped partway, and 2 when the journal could not be read or is not well formed. The interpreter writes to standard output, as <RT·code><tag>: <message></RT·code>, the highlights of what it does, as its developer chooses them.
</p>
<p>
- Execution stops at the first error. What the interpreter did before then stays done, except what its instruction language undoes; for the file language, see <a href="#sec-file-language"><RT·Counter·read snapshot="sec-file-language" key="counter count"></RT·Counter·read></a>. Playing a journal as a transaction is a possible future feature; see <a href="#sec-transaction"><RT·Counter·read snapshot="sec-transaction" key="counter count"></RT·Counter·read></a>.
+ Execution stops at the first error. What the interpreter did before then stays done, except what its instruction language undoes; for the example language, see <a href="#sec-example"><RT·Counter·read snapshot="sec-example" key="counter count"></RT·Counter·read></a>. Playing a journal as a transaction is a possible future feature; see <a href="#sec-transaction"><RT·Counter·read snapshot="sec-transaction" key="counter count"></RT·Counter·read></a>.
</p>
</RT·section>
<RT·section id="sec-language">
<RT·name>Instruction language</RT·name>
<p>
- An instruction language is defined once and used from both sides. Its composer half is its journal interface: one function per instruction, which checks the call's arguments, raises an error on a wrong one, and places the instruction. Its other half is the checks the guard makes, the constraint check among them, and the code the interpreter runs for each instruction. Keeping both halves in one module keeps the two checks in step.
+ An instruction language is defined by the application developer and is built into the application. On the A side of the account divide, the composer writes the language by calling functions on the journal interface, one interface function per instruction. On the B account side of the divide, the guard checks the journal for correct syntax and that other constraints are met.
</p>
<p>
- The instruction language is not specific to Man_In_Grey. An application that mounts home directories has a language of mount and unmount; one that manages accounts has a language for making and deleting them. Each gate starts the guard and the interpreter of one instruction language, and they refuse a journal written in another.
+ The instruction language is not specific to Man_In_Grey. An application that mounts home directories has a language of mount and unmount; one that manages accounts has a language for making and deleting them. Each gate starts the guard and the interpreter of one instruction language, and they refuse a journal written in another language. These coherency checks are due to the journal carrying the name of its language, and due to syntax checking of the constituent instructions passing.
</p>
<p>
- A language can be made as narrow as its application. The narrower it is, the less a journal can say, and the less the guard has to rule out. The file language can name any file, so its constraints list the files allowed. The example language, <a href="#sec-example"><RT·Counter·read snapshot="sec-example" key="counter count"></RT·Counter·read></a>, can name no file at all: what it writes, and where, follows from the account that gives the journal.
+ A language can be made as narrow as its application. The narrower it is, the less a journal can say, and the less the guard has to rule out. The example language, <a href="#sec-example"><RT·Counter·read snapshot="sec-example" key="counter count"></RT·Counter·read></a>, can name no file at all: what it writes, and where, is partially compiled in, and partially derived from the name of the invoking user account.
+ </p>
+ <p>
+ Narrowness matters most for content. Constraints on where a file goes, and with what owner and permission, do not make the file safe when account A writes every byte of it. A configuration file is read by a program, often one started by root, and what the file says is what that program does; <RT·code>unbound.conf</RT·code>, for one, can name a Python script for the daemon to load. A language that lets account A write such a file freely gives account A much of the power of the program that reads it, however tightly its constraints fix the destination. The safe design is a language narrow enough that every journal it can express is safe: its interpreter writes the file, and account A supplies only values the language has a place for, as in the example.
</p>
-
- <RT·section id="sec-file-language">
- <RT·name>The file language</RT·name>
- <p>
- Man_In_Grey's own instruction language is a constrained form of the POSIX file interface:
- </p>
- <RT·dictionary key="Instruction" definition="Meaning">
- <RT·entry key="open(directory ,name ,mode ,owner ,permission)">
- Produces a symbol for the file. A relative directory is made absolute from the composer's working directory. <RT·code>permission</RT·code> is an integer such as <RT·code>0o644</RT·code>, or three or four octal digits such as <RT·code>"0644"</RT·code>.
- </RT·entry>
- <RT·entry key="write(file ,content)">
- Appends content, text or bytes, to the file an <RT·code>open</RT·code> produced. Text is encoded as UTF-8.
- </RT·entry>
- <RT·entry key="unlink(directory ,name)">
- Removes a file. A file that is absent is not an error.
- </RT·entry>
- </RT·dictionary>
- <RT·dictionary key="Mode of open" definition="Meaning">
- <RT·entry key="create">
- The file must not exist.
- </RT·entry>
- <RT·entry key="replace">
- An existing file is discarded.
- </RT·entry>
- <RT·entry key="replace_keep">
- An existing file is kept as <RT·code><name>.YYYYMMDDTHHMMSSZ</RT·code>, the time in UTC, with <RT·code>.1</RT·code>, <RT·code>.2</RT·code> added when that name is taken.
- </RT·entry>
- </RT·dictionary>
- <p>
- There is no <RT·code>close</RT·code>. Each opened file is written to a temporary file in its own directory, and after the journal's last instruction every opened file is given its owner and permission and put in place, each in one step: a rename for <RT·code>replace</RT·code>, a hard link that refuses an existing name for <RT·code>create</RT·code>, and for <RT·code>replace_keep</RT·code> a hard link of the old file to its backup name followed by the rename, so the file's name is never absent. Every directory is opened without following a symbolic link, and names are worked on inside the open directory.
- </p>
- <p>
- Execution stops at the first error, and every temporary file not yet put in place is removed. An <RT·code>unlink</RT·code> earlier in the journal has already happened by then. The interpreter writes a line for each file it puts in place or removes, such as <RT·code>replaced: /etc/unbound/unbound-US.conf (kept as unbound-US.conf.20261105T083807Z)</RT·code>, and none for an <RT·code>open</RT·code> or a <RT·code>write</RT·code>.
- </p>
- <p>
- The guard checks a journal in four steps, each over every instruction, and stops at the first step that finds a problem. It reports each problem as <RT·code><step>_broken</RT·code> and each step that passes as <RT·code>check_passed</RT·code>:
- </p>
- <RT·dictionary key="Check" definition="What it looks at">
- <RT·entry key="wellformed">
- Known instructions with exactly their arguments, of the right kinds; absolute directories in normal form; bare file names; no control character in a directory, file name or owner; every <RT·code>write</RT·code> naming the symbol of an <RT·code>open</RT·code>.
- </RT·entry>
- <RT·entry key="constraint">
- <RT·code>is_allowed_account</RT·code> allows account A; <RT·code>is_allowed_open</RT·code> allows each file opened, in its mode, with its owner and permission; <RT·code>is_allowed_unlink</RT·code> allows each file removed.
- </RT·entry>
- <RT·entry key="validity">
- Every owner names an account.
- </RT·entry>
- <RT·entry key="filesystem">
- Every directory exists, opens, and passes through no symbolic link; no file is named by two instructions; a file opened in <RT·code>create</RT·code> mode does not exist; an existing file to be replaced or unlinked is a regular file.
- </RT·entry>
- </RT·dictionary>
- <p>
- The constraints at the top of the file language's guard, <RT·code>guard_file.py</RT·code>, allowing Thomas to keep one unbound configuration file and to remove another:
- </p>
- <RT·code>
- def is_allowed_account(user_name):
- return user_name == "Thomas"
-
- def is_allowed_open(directory ,name ,mode ,owner ,permission):
- return (directory ,name ,mode ,owner ,permission) == ("/etc/unbound" ,"unbound-US.conf" ,"replace_keep" ,"root" ,0o644)
-
- def is_allowed_unlink(directory ,name):
- return (directory ,name) == ("/etc/unbound" ,"unbound-old.conf")
- </RT·code>
- <p>
- The permission is given to <RT·code>is_allowed_open</RT·code> as an integer, so <RT·code>0o644</RT·code> and <RT·code>"0644"</RT·code> in a composer both arrive as <RT·code>0o644</RT·code>.
- </p>
- </RT·section>
</RT·section>
<RT·section id="sec-journal">
<RT·name>Journal</RT·name>
<p>
- A journal is a CBOR map:
+ A journal is a CBOR map of four entries:
</p>
<RT·dictionary key="Entry" definition="Meaning">
<RT·entry key="version">
- 3.
+ The version of the journal's form, now 3. A guard or interpreter refuses a journal of another version.
</RT·entry>
<RT·entry key="instruction_language">
- The name of the instruction language, such as <RT·code>file</RT·code>.
+ The name of the instruction language the journal is written in, such as <RT·code>emplace_bio</RT·code>. A guard and an interpreter refuse a journal in a language other than their own.
</RT·entry>
<RT·entry key="meta">
- When, where and by whom it was composed, and the composers that ran.
+ When, on which host and by which account the journal was composed, the composer directory, and the composers that ran, in order.
</RT·entry>
<RT·entry key="instruction_list">
- The instructions, each a map of <RT·code>name</RT·code>, <RT·code>argument</RT·code>, and <RT·code>composer</RT·code>. An argument that is a symbol is the map <RT·code>{"symbol": <index>}</RT·code>.
+ The instructions, in the order the composers placed them.
</RT·entry>
</RT·dictionary>
+ <p>
+ Each instruction is a map of three entries: <RT·code>name</RT·code>, which instruction it is; <RT·code>argument</RT·code>, a map holding all of the instruction's arguments, each keyed by argument name; and <RT·code>composer</RT·code>, the name of the composer function that placed it. The first four instructions of the journal in <a href="#sec-example"><RT·Counter·read snapshot="sec-example" key="counter count"></RT·Counter·read></a>, decoded, and written in JSON notation:
+ </p>
+ <RT·code>
+ {"name": "display_name", "argument": {"text": "Thomas"}, "composer": "bio.py"}
+ {"name": "paragraph", "argument": {}, "composer": "bio.py"}
+ {"name": "text", "argument": {"content": "I write ", "style": []}, "composer": "bio.py"}
+ {"name": "text", "argument": {"content": "software", "style": ["emphasis"]}, "composer": "bio.py"}
+ </RT·code>
+ <p>
+ An instruction that takes no arguments, such as <RT·code>paragraph</RT·code>, has an empty argument map. Arguments are given by name rather than by position, so the guard's wellformed check can require exactly the arguments an instruction takes: a <RT·code>text</RT·code> instruction carrying a third argument is refused, whatever that argument says.
+ </p>
+ <p>
+ An argument that is a symbol is the map <RT·code>{"symbol": <index>}</RT·code>, where the index locates the instruction that produces the value. In the account language of <a href="#sec-symbol"><RT·Counter·read snapshot="sec-symbol" key="counter count"></RT·Counter·read></a>, the second instruction names the result of the first:
+ </p>
+ <RT·code>
+ {"name": "make_account", "argument": {"name": "Thomas·www"}, "composer": "www.py"}
+ {"name": "add_to_group", "argument": {"account": {"symbol": 0}, "group": "http"}, "composer": "www.py"}
+ </RT·code>
+ <p>
+ The orchestrator runs the composers one after another into a single journal. Before it calls each composer's <RT·code>compose</RT·code>, it records that composer's path, relative to the composer directory, and every instruction placed during the call carries it. A journal composed from a directory of composers therefore shows which composer placed each instruction, and <RT·code>print</RT·code> shows it at the end of each line, which helps in finding the composer that placed a wrong one. The <RT·code>composer</RT·code> entry, like <RT·code>meta</RT·code>, is a note, not evidence: account A writes the whole journal, and could write anything there, so the guard and the interpreter never base a decision on either.
+ </p>
</RT·section>
<RT·section id="sec-transport">
<RT·section>
<RT·name>Building</RT·name>
<p>
- Man_In_Grey is a Harmony project with two namespaces: <RT·code>Man_In_Grey.copy</RT·code>, the Python programs, and <RT·code>Man_In_Grey_gate.make</RT·code>, the general gate and the gates of the file language and the example language. In the developer role, from its directory <RT·code>developer/</RT·code>, <RT·code>build "*"</RT·code> and <RT·code>promote write</RT·code> place them in the following layout, which is how each part finds the others:
+ Man_In_Grey is a Harmony project with two namespaces: <RT·code>Man_In_Grey.copy</RT·code>, the Python programs, and <RT·code>Man_In_Grey_gate.make</RT·code>, the general gate, the example language's gate, and the gate of the smoke test's language. In the developer role, from its directory <RT·code>developer/</RT·code>, <RT·code>build "*"</RT·code> and <RT·code>promote write</RT·code> place them in the following layout, which is how each part finds the others:
</p>
<RT·code>
consumer/Man_In_Grey/Man_In_Grey the command a person runs
consumer/Man_In_Grey/Guard.py the general guard
consumer/Man_In_Grey/Constraint.py predicates that recur among guards' constraints
consumer/Man_In_Grey/Interpreter.py the general interpreter
- consumer/Man_In_Grey/FileLanguage.py the file language: interface, checks, execution
- consumer/Man_In_Grey/guard_file.py the file language's guard, its constraints at its top
- consumer/Man_In_Grey/interpreter_file.py the file language's interpreter
- consumer/Man_In_Grey/EmplaceBioLanguage.py the example language
- consumer/Man_In_Grey/guard_emplace_bio.py its guard
+ consumer/Man_In_Grey/EmplaceBioLanguage.py the example language: interface, checks, execution
+ consumer/Man_In_Grey/guard_emplace_bio.py its guard, with its constraints at its top
consumer/Man_In_Grey/interpreter_emplace_bio.py its interpreter
- consumer/Man_In_Grey_gate/Man_In_Grey_file_gate the file language's gate
consumer/Man_In_Grey_gate/Man_In_Grey_emplace_bio_gate the example language's gate
+ consumer/Man_In_Grey_gate/Man_In_Grey_smoke_gate the smoke test's gate, with SmokeLanguage.py,
+ guard_smoke.py and interpreter_smoke.py
consumer/Man_In_Grey_gate/libMan_In_Grey.a the general gate, for other gates
</RT·code>
<p>
<RT·section id="sec-composer">
<RT·name>Writing a composer</RT·name>
<p>
- Composers are kept in a composer directory, named <RT·code>composer</RT·code> in the examples here. Every file in it is a composer, a Python file defining <RT·code>compose(prov ,journal_iface)</RT·code>; notes such as a README belong a directory up. Its subdirectories are namespaces for the author. They set the order the composers run in: depth first, in order of name at each level, files and subdirectories together, by Unicode code point, so capital letters come before small ones. A composer's place says nothing else; its <RT·code>open</RT·code> and <RT·code>unlink</RT·code> instructions say where its files go.
+ Composers are kept in a composer directory, named <RT·code>composer</RT·code> in the examples here. Every file in it is a composer, a Python file defining <RT·code>compose(prov ,journal_iface)</RT·code>; notes such as a README belong a directory up. Its subdirectories are namespaces for the author. They set the order the composers run in: depth first, in order of name at each level, files and subdirectories together, by Unicode code point, so capital letters come before small ones. A composer's place says nothing else; what a composer does is said by the instructions it places.
</p>
<p>
A composer runs with the orchestrator's uids, which are normally account A's, and so with account A's rights. If another account could change a composer, or add one to a composer directory, that account's code would run with account A's rights, and its instructions would reach the gate in account A's name. Every composer, and every directory from <RT·code>/</RT·code> down to it, is therefore to be owned by account A or by root, and writable by no other account.
This ownership protects account A, not account B, and the gate cannot enforce it. The gate is given a journal, never the program that made it, and anything it is told about the composers comes from account A. To the gate and the guard, every journal is account A's, whatever made it. Composer ownership is therefore a convention, kept by whoever installs an application's composers. Man_In_Grey's orchestrator keeps it for its own runs: before running any composer it makes the check, and it refuses a symbolic link in a composer directory. An application that acts as its own orchestrator keeps the convention itself. To run a composer someone else wrote, review it and copy it into your own composer directory.
</p>
<RT·code>
- # composer/unbound-US.conf.py
+ # composer/bio.py
def compose(prov ,journal_iface):
- file = journal_iface.open("/etc/unbound" ,prov.implied_fname ,"replace_keep" ,"root" ,0o644)
- journal_iface.write(file ,"server:\n verbosity: 1\n")
+ journal_iface.display_name("Thomas")
+ journal_iface.paragraph()
+ journal_iface.text("I write software." ,[])
</RT·code>
+ <p>
+ This composer writes in the example language; <a href="#sec-example"><RT·Counter·read snapshot="sec-example" key="counter count"></RT·Counter·read></a> shows a whole one. <RT·code>compose</RT·code> is given <RT·code>prov</RT·code>, which says where the composer is:
+ </p>
<RT·dictionary key="Field of prov" definition="Meaning">
<RT·entry key="implied_fname">
The composer's own file name less <RT·code>.py</RT·code>, for a composer named for the file it writes.
</RT·entry>
</RT·dictionary>
<p>
- <RT·code>Man_In_Grey orchestrate <path></RT·code> runs the one composer in a composer file, or every composer in a composer directory. No option given to Man_In_Grey reaches <RT·code>compose</RT·code>; a composer's settings are written in the composer itself. The <RT·code>journal_iface</RT·code> a composer is given is that of the gate's instruction language: the orchestrator asks the gate for its flags, and loads the module named for its <RT·code>language</RT·code>, <RT·code>FileLanguage.py</RT·code> for <RT·code>file</RT·code> and <RT·code>EmplaceBioLanguage.py</RT·code> for <RT·code>emplace_bio</RT·code>.
+ <RT·code>Man_In_Grey orchestrate <path></RT·code> runs the one composer in a composer file, or every composer in a composer directory. No option given to Man_In_Grey reaches <RT·code>compose</RT·code>; a composer's settings are written in the composer itself. The <RT·code>journal_iface</RT·code> a composer is given is that of the gate's instruction language: the orchestrator asks the gate for its flags, and loads the module named for its <RT·code>language</RT·code>, <RT·code>EmplaceBioLanguage.py</RT·code> for <RT·code>emplace_bio</RT·code>.
</p>
</RT·section>
A run that changes files is best preceded by two that do not. The first shows the journal while nothing has left the person's account; the second has the guard check the journal, and the gate stop before the interpreter:
</p>
<RT·code>
- Man_In_Grey orchestrate composer print compose_only
- Man_In_Grey orchestrate composer check_only
- Man_In_Grey orchestrate composer
+ Man_In_Grey orchestrate composer gate <gate> print compose_only
+ Man_In_Grey orchestrate composer gate <gate> check_only
+ Man_In_Grey orchestrate composer gate <gate>
</RT·code>
<RT·dictionary key="Command or option" definition="Meaning">
<RT·entry key="orchestrate <path>">
Run the composer in a composer file, or every composer in a composer directory, and invoke the gate with the journal.
</RT·entry>
<RT·entry key="gate <file>">
- The gate to invoke, which also sets the instruction language the composers write in. Default: <RT·code>../Man_In_Grey_gate/Man_In_Grey_file_gate</RT·code> from <RT·code>Man_In_Grey.py</RT·code>.
+ The gate to invoke, which also sets the instruction language the composers write in. Required: the orchestrator has no gate of its own.
</RT·entry>
<RT·entry key="print">
Print the journal after composing.
</RT·entry>
</RT·dictionary>
<p>
- The command and its path come first, and the options follow, as plain words; given no arguments, <RT·code>Man_In_Grey</RT·code> prints its help, and so does the gate. The exit status is 0 when the run did what it was asked; 1 when the guard refused the journal or an instruction failed to execute; and 2 when the run could not start, a composer failed or was refused, or the gate, guard or interpreter could not run or broke the gate's rules. The guard's and interpreter's lines are also in the system log; on a system with journald, <RT·code>journalctl -t Man_In_Grey_gate</RT·code> shows them. To undo a <RT·code>replace_keep</RT·code>, rename the backup back.
+ The command and its path come first, then the gate, and the options follow, as plain words; given no arguments, <RT·code>Man_In_Grey</RT·code> prints its help, and so does the gate. The exit status is 0 when the run did what it was asked; 1 when the guard refused the journal or an instruction failed to execute; and 2 when the run could not start, a composer failed or was refused, or the gate, guard or interpreter could not run or broke the gate's rules. The guard's and interpreter's lines are also in the system log; on a system with journald, <RT·code>journalctl -t Man_In_Grey_gate</RT·code> shows them.
</p>
</RT·section>
<RT·section>
<RT·name>Testing</RT·name>
<p>
- The tester has two tests. Each copies the promoted Man_In_Grey into its scratchpad directory, puts constraints of its own at the top of the copy's guard, and runs the copy through a gate that is not setuid, so accounts A and B are the same. The gate checks ownership on every run, so the test makes its copy writable by its owner alone, and every directory from <RT·code>/</RT·code> down to the scratchpad directory is to be writable by no account but its owner too. The test <RT·code>tester/authored/write_file</RT·code> runs the file language's gate, and checks twenty-five things. Two runs of composers using every mode and <RT·code>unlink</RT·code> are compared with <RT·code>expected/run_1</RT·code> and <RT·code>expected/run_2</RT·code>, with backup times replaced by the letters <RT·code>YYYYMMDDTHHMMSSZ</RT·code>. A file opened in <RT·code>create</RT·code> mode is written once and refused the second time. A journal with one allowed file and one the constraints do not list is refused whole. A composer giving a wrong mode fails before any journal reaches the gate. A composer directory holding a composer other accounts can write is refused by the orchestrator. A <RT·code>check_only</RT·code> run is approved by the guard and changes nothing. A run under a file size limit the gate cannot raise is refused before the guard starts. Each refusal leaves every file as it was. Since the gate checks ownership on every run, every directory from <RT·code>/</RT·code> down to the promoted interpreter is to be writable by no account but its owner. In the tester role, from its directory <RT·code>tester/</RT·code>:
+ The tester's test <RT·code>tester/authored/emplace_bio</RT·code> copies the promoted Man_In_Grey into its scratchpad directory, puts constraints of its own at the top of the copy's guard, and runs the copy through the example language's gate, not setuid, so accounts A and B are the same. The gate checks ownership on every run, so the test makes its copy writable by its owner alone, and every directory from <RT·code>/</RT·code> down to the scratchpad directory is to be writable by no account but its owner too.
</p>
- <RT·code>
- authored/write_file/run test
- authored/write_file/run clean
- </RT·code>
<p>
- The test <RT·code>tester/authored/emplace_bio</RT·code> runs the example language's gate, and checks twenty-six things. A page with every style and with text that looks like markup is compared with <RT·code>expected/page.html</RT·code>, and its picture with the one given. A later run with a JPEG places it and removes the PNG, and a run with no picture removes the JPEG. A <RT·code>check_only</RT·code> run changes nothing. Seven journals are then made without the journal interface and given to the gate directly, as a program other than the orchestrator could: one whose text instruction carries a file name, one with a style the language does not have, one with text before any paragraph, one with an SVG picture, one with more text than its constraints allow, one from an account they do not allow, and one for a page that has been made a symbolic link. Each is refused by the check that is to refuse it, and changes nothing:
+ It checks twenty-six things. A page with every style and with text that looks like markup is compared with <RT·code>expected/page.html</RT·code>, and its picture with the one given. A later run with a JPEG places it and removes the PNG, and a run with no picture removes the JPEG. A <RT·code>check_only</RT·code> run changes nothing. Seven journals are then made without the journal interface and given to the gate directly, as a program other than the orchestrator could: one whose text instruction carries a file name, one with a style the language does not have, one with text before any paragraph, one with an SVG picture, one with more text than its constraints allow, one from an account they do not allow, and one for a page that has been made a symbolic link. Each is refused by the check that is to refuse it, and changes nothing:
</p>
<RT·code>
authored/emplace_bio/run test
authored/emplace_bio/run clean
</RT·code>
+ <p>
+ The tester's smoke test, <RT·code>authored/smoke/run test</RT·code>, exercises the gate, the guard and the interpreter through a language of its own that is not for use on a system.
+ </p>
</RT·section>
</RT·section>
<RT·entry key="The gate is built without hardening">
The old compile script built the gate with <RT·code>-z relro</RT·code>, <RT·code>-z now</RT·code> and <RT·code>-fstack-protector-strong</RT·code>. Harmony's build has no flags for one namespace, so the gate is built without them.
</RT·entry>
- <RT·entry key="An unlink is not undone">
- Execution stops at the first error and removes the temporary files of files not yet put in place, but an <RT·code>unlink</RT·code> that ran earlier in the journal stays done.
- </RT·entry>
<RT·entry key="Checks and execution are separate moments">
- The filesystem check and execution are separate, so a file can appear or change between them. Execution then fails on that instruction rather than acting on the change: a <RT·code>create</RT·code> whose file has appeared is refused by the hard link.
+ The guard's filesystem check and the interpreter's execution are separate moments, so a file can appear or change between them. An interpreter is to be written so that it fails on such a change rather than acting on it: the example language's interpreter opens its directory without following a symbolic link, and a rename into place replaces a symbolic link put there since rather than following it.
</RT·entry>
<RT·entry key="Account A can signal the gate">
- A process whose real or effective uid is account A's can send signals to the gate, the guard and the interpreter, whose real uid is account A's, and so can kill the interpreter partway through a journal. The file language puts its files in place only after the last instruction, which keeps the window small, but an <RT·code>unlink</RT·code> already done, or a file already put in place, stays as it is.
+ A process whose real or effective uid is account A's can send signals to the gate, the guard and the interpreter, whose real uid is account A's, and so can kill the interpreter partway through a journal. The example language writes its files to temporary names and renames each into place, so a page is never half written, but a file already put in place stays as it is.
</RT·entry>
<RT·entry key="A guard can pass without reading">
The gate checks the guard's verdict, not the guard's work: a guard that writes its approval line without reading the journal approves everything. The guard is installed code the application developer writes, and is to be tested as such.
A Btrfs subvolume is a directory tree that can be snapshotted. Making a snapshot takes about as long as making a directory, whatever the size of the tree, because the snapshot shares every block with its source; a block is copied only when one side writes it. Before playing a journal, the interpreter would snapshot each subvolume the journal writes in. When the play succeeds, it deletes the snapshots. When the play fails, it puts back each file it changed from the snapshot, then deletes the snapshots. It knows which files those are, since it touched them.
</p>
<p>
- Putting back files one at a time is the general case. A running system cannot replace the subvolume it runs from, such as the one holding <RT·code>/etc</RT·code>, so there the rollback is a restore of each changed file, each in one step, by the same rename the file language uses to put a file in place. Where the destination is a subvolume of its own that is not mounted separately and that no program depends on keeping open, such as a web server's page directory, the interpreter could instead play the journal into a snapshot and then exchange the snapshot and the subvolume in one call, <RT·code>renameat2</RT·code> with <RT·code>RENAME_EXCHANGE</RT·code>, so the whole change appears at once or not at all. Whether Btrfs allows that exchange between subvolumes is to be confirmed before the design relies on it.
+ Putting back files one at a time is the general case. A running system cannot replace the subvolume it runs from, such as the one holding <RT·code>/etc</RT·code>, so there the rollback is a restore of each changed file, each in one step, by the same rename the example language uses to put a file in place. Where the destination is a subvolume of its own that is not mounted separately and that no program depends on keeping open, such as a web server's page directory, the interpreter could instead play the journal into a snapshot and then exchange the snapshot and the subvolume in one call, <RT·code>renameat2</RT·code> with <RT·code>RENAME_EXCHANGE</RT·code>, so the whole change appears at once or not at all. Whether Btrfs allows that exchange between subvolumes is to be confirmed before the design relies on it.
</p>
<p>
- The cost is privilege. Making a snapshot needs ownership of the subvolume, or root, and write access to the directory the snapshot goes in. Deleting one needs <RT·code>CAP_SYS_ADMIN</RT·code>, unless the file system is mounted with <RT·code>user_subvol_rm_allowed</RT·code>. A gate whose account B is not root would need the second, and a gate holding only the capabilities its language needs would need the first, which is nearly root by itself. On other file systems the play stays as it is, and a language undoes its own work where it can, as the file language does by putting its files in place only after its last instruction.
+ The cost is privilege. Making a snapshot needs ownership of the subvolume, or root, and write access to the directory the snapshot goes in. Deleting one needs <RT·code>CAP_SYS_ADMIN</RT·code>, unless the file system is mounted with <RT·code>user_subvol_rm_allowed</RT·code>. A gate whose account B is not root would need the second, and a gate holding only the capabilities its language needs would need the first, which is nearly root by itself. On other file systems the play stays as it is, and a language undoes its own work where it can, as the example language does by writing its files to temporary names and renaming each into place.
</p>
</RT·section>
</RT·section>
--- /dev/null
+# replace: the second run discards the first run's file, keeping no backup.
+# The content is written in two parts.
+
+def compose(prov ,journal_iface):
+ file = journal_iface.open("out/net" ,prov.implied_fname ,"replace" ,prov.process_user ,"0444")
+ journal_iface.write(file ,"server:\n")
+ journal_iface.write(file ," verbosity: 1\n")
--- /dev/null
+# unlink: the first run removes out/obsolete.conf; the second finds nothing to remove.
+
+def compose(prov ,journal_iface):
+ journal_iface.unlink("out" ,"obsolete.conf")
--- /dev/null
+# replace_keep: the second run keeps the first run's file as a timestamped backup.
+# The file name is the one this composer implies: its own name less .py.
+
+def compose(prov ,journal_iface):
+ file = journal_iface.open("out" ,prov.implied_fname ,"replace_keep" ,prov.process_user ,0o444)
+ journal_iface.write(file ,"server:\n do-ip6: no\n")
--- /dev/null
+# replace_keep, under a file name given here rather than implied.
+
+def compose(prov ,journal_iface):
+ file = journal_iface.open("out/web" ,"nginx.conf" ,"replace_keep" ,prov.process_user ,"0644")
+ journal_iface.write(file ,"events {}\nhttp { server { listen 8080; } }\n")
--- /dev/null
+# A wrong argument: the journal interface raises an error, and no journal reaches the gate.
+
+def compose(prov ,journal_iface):
+ journal_iface.open("out" ,"unbound_conf" ,"overwrite" ,prov.process_user ,0o444)
--- /dev/null
+# create: allowed once; a second run is refused, as the file then exists.
+
+def compose(prov ,journal_iface):
+ file = journal_iface.open("out" ,"new.conf" ,"create" ,prov.process_user ,0o600)
+ journal_iface.write(file ,"made once\n")
--- /dev/null
+# One file the constraints allow, and one they do not. The whole journal is refused.
+
+def compose(prov ,journal_iface):
+ allowed = journal_iface.open("out" ,"unbound_conf" ,"replace_keep" ,prov.process_user ,0o444)
+ journal_iface.write(allowed ,"this is not to be written\n")
+ forbidden = journal_iface.open("out" ,"forbidden.conf" ,"replace" ,prov.process_user ,0o644)
+ journal_iface.write(forbidden ,"nor this\n")
--- /dev/null
+server:
+ verbosity: 1
--- /dev/null
+server:
+ do-ip6: no
--- /dev/null
+events {}
+http { server { listen 8080; } }
--- /dev/null
+server:
+ verbosity: 1
--- /dev/null
+server:
+ do-ip6: no
--- /dev/null
+server:
+ do-ip6: no
--- /dev/null
+events {}
+http { server { listen 8080; } }
--- /dev/null
+events {}
+http { server { listen 8080; } }
--- /dev/null
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+run
+
+The smoke test of Man_In_Grey, run through the smoke language. It copies
+the promoted Man_In_Grey, from $REPO_HOME/consumer, into its scratchpad
+directory, puts constraints of its own at the top of the copy's
+guard_smoke.py, allowing the files it expects, and runs that copy through
+the smoke language's gate, not setuid. The smoke language is not for use
+on a system; it is here to exercise the gate, the guard and the
+interpreter. It checks:
+
+ run_1 orchestrate composer/: each composer's file written, and
+ out/obsolete.conf, made by the test beforehand, unlinked; the
+ files are compared with expected/run_1
+ run_2 orchestrate composer/ again: replace_keep files kept as backups named
+ <file>.YYYYMMDDTHHMMSSZ, the replace file overwritten with no
+ backup; compared with expected/run_2
+ create orchestrate composer_create/new.py: a file opened in create mode is written
+ once, and a second run is refused by the guard's filesystem check
+ refused orchestrate composer_refused/: a journal opening one allowed file and one the
+ constraints do not allow is refused whole by the guard's constraint check
+ argument orchestrate composer_argument/: a composer giving a wrong mode fails when it
+ places the instruction, and no journal reaches the gate
+ writable orchestrate a composer directory holding a composer other accounts can write:
+ the orchestrator refuses to run it
+ check_only orchestrate composer/ with check_only: the guard approves the journal, and the
+ interpreter is not run
+ limit orchestrate composer/ with a file size limit the gate cannot raise: the gate
+ refuses before starting the guard
+
+A refused run is to leave every file as it was. Backup names carry the time
+of the run, so names are compared with the time replaced by the letters
+YYYYMMDDTHHMMSSZ. Content, mode and owner are compared too.
+
+The test reads consumer/ and writes only in its scratchpad directory,
+$REPO_HOME/tester/scratchpad/smoke, where it runs its copy of
+Man_In_Grey from tree/.
+
+Usage: run <command>
+ help | -h | --help print this message (also printed when given no arguments)
+ test run the test; the exit status is 0 only when every check passes
+ clean delete the test's scratchpad directory
+"""
+
+import sys ,os ,re ,stat ,json ,shutil ,subprocess ,getpass ,resource
+from pathlib import Path
+
+TEST_NAME = "smoke"
+GATE_NAME = "Man_In_Grey_smoke_gate"
+TEST_DPATH = Path(__file__).resolve().parent
+
+OUTPUT_DIR = ("out" ,"out/net" ,"out/web")
+
+# the files the constraints allow: (directory under the scratchpad, name, modes, permission)
+ALLOWED = (
+ ("out" ,"unbound_conf" ,["replace_keep"] ,"0444")
+ ,("out/net" ,"unbound.conf" ,["replace"] ,"0444")
+ ,("out/web" ,"nginx.conf" ,["replace_keep"] ,"0644")
+ ,("out" ,"obsolete.conf" ,["unlink"] ,"0644")
+ ,("out" ,"new.conf" ,["create"] ,"0600")
+)
+
+MODE = {
+ "run_1": {
+ "out/unbound_conf": 0o444
+ ,"out/net/unbound.conf": 0o444
+ ,"out/web/nginx.conf": 0o644
+ },
+ "run_2": {
+ "out/unbound_conf": 0o444
+ ,"out/unbound_conf.YYYYMMDDTHHMMSSZ": 0o444
+ ,"out/net/unbound.conf": 0o444
+ ,"out/web/nginx.conf": 0o644
+ ,"out/web/nginx.conf.YYYYMMDDTHHMMSSZ": 0o644
+ },
+}
+
+TIME_SUFFIX = re.compile(r"\.\d{8}T\d{6}Z(\.\d+)?$")
+TIME_PLACEHOLDER = ".YYYYMMDDTHHMMSSZ"
+
+
+# --------------------------------------------------------------------------------
+# the log
+
+TAG_WIDTH = 10
+
+class Log:
+ def __init__(self):
+ self.count = {"passed": 0 ,"failed": 0}
+
+ def check(self ,passed ,what ,detail=None):
+ tag = "passed" if passed else "failed"
+ self.count[tag] += 1
+ print(f" {tag:<{TAG_WIDTH}}{what}" + (f" ({detail})" if detail else ""))
+ return passed
+
+ def note(self ,text):
+ for line in text.splitlines():
+ print(f" {'':<{TAG_WIDTH}}{line}")
+
+ def summary(self):
+ print("summary:")
+ print(f" {self.count['passed']:>4} checks passed")
+ print(f" {self.count['failed']:>4} checks failed")
+ return 0 if self.count["failed"] == 0 else 1
+
+
+# --------------------------------------------------------------------------------
+# the work
+
+def tree(root):
+ """
+ The files under root as {name: path}, the name relative to root with any
+ backup time replaced by the placeholder; a name met twice is listed with
+ a count, so two backups of one file are seen.
+ """
+ result = {}
+ for path in sorted(p for p in root.rglob("*") if p.is_file() or p.is_symlink()):
+ name = TIME_SUFFIX.sub(TIME_PLACEHOLDER ,path.relative_to(root).as_posix())
+ if name in result:
+ n = 2
+ while f"{name} (copy {n})" in result: n += 1
+ name = f"{name} (copy {n})"
+ result[name] = path
+ return result
+
+def snapshot(root):
+ "every file under root, by its exact name, with its content, mode and owner"
+ result = {}
+ for path in sorted(p for p in root.rglob("*") if p.is_file()):
+ st = path.lstat()
+ result[path.relative_to(root).as_posix()] = (path.read_bytes() ,stat.S_IMODE(st.st_mode) ,st.st_uid)
+ return result
+
+def compare(log ,run_name ,work_dpath):
+ expected = tree(TEST_DPATH / "expected" / run_name)
+ found = {n: p for n ,p in tree(work_dpath).items() if n.startswith("out/")}
+ for name in sorted(set(expected) | set(found)):
+ what = f"{run_name} {name}"
+ if name not in found:
+ log.check(False ,what ,"expected, not written"); continue
+ if name not in expected:
+ log.check(False ,what ,"written, not expected"); continue
+ path = found[name]
+ problem = []
+ if path.read_bytes() != expected[name].read_bytes():
+ problem.append(f"content differs from expected/{run_name}/{name}")
+ mode = stat.S_IMODE(path.lstat().st_mode)
+ want = MODE[run_name].get(name)
+ if want is not None and mode != want:
+ problem.append(f"mode {mode:04o}, expected {want:04o}")
+ if path.lstat().st_uid != os.getuid():
+ problem.append(f"owner uid {path.lstat().st_uid}, expected {os.getuid()}")
+ log.check(not problem ,what ,"; ".join(problem) or None)
+
+def limit_file_size():
+ "run in the child before Man_In_Grey starts: a file size limit, soft and hard, of 1 MiB"
+ resource.setrlimit(resource.RLIMIT_FSIZE ,(1 << 20 ,1 << 20))
+
+def run_Man_In_Grey(program ,path ,work_dpath ,*option ,preexec=None):
+ gate = program.parent.parent / "Man_In_Grey_gate" / GATE_NAME
+ command = [str(program) ,"orchestrate" ,str(path) ,"gate" ,str(gate) ,*option]
+ result = subprocess.run(command ,cwd=work_dpath ,capture_output=True ,text=True ,preexec_fn=preexec)
+ return result.returncode ,(result.stdout + result.stderr).strip()
+
+def check_status(log ,what ,status ,want ,output ,want_text=None):
+ passed = status == want and (want_text is None or want_text in output)
+ detail = None
+ if status != want:
+ detail = f"exit status {status}, expected {want}"
+ elif not passed:
+ detail = f"the output does not say {want_text!r}"
+ log.check(passed ,what ,detail)
+ if not passed and output:
+ log.note(output)
+ return passed
+
+def make_tree(consumer ,tree):
+ """
+ A copy of the promoted Man_In_Grey/ and Man_In_Grey_gate/, side by side, in
+ tree. The gate refuses a guard in a directory other accounts can write,
+ so tree and the directory holding it are made writable by the owner alone.
+ """
+ tree.mkdir(parents=True)
+ for each in (tree.parent ,tree):
+ os.chmod(each ,0o755)
+ for name in ("Man_In_Grey" ,"Man_In_Grey_gate"):
+ (tree / name).mkdir()
+ os.chmod(tree / name ,0o755)
+ for source in sorted((consumer / name).iterdir()):
+ if source.is_file():
+ shutil.copy2(source ,tree / name / source.name)
+
+def put_constraint(guard_fpath ,constraint):
+ "replace the predicates between the guard's constraint marks with constraint"
+ line_list = guard_fpath.read_text(encoding="utf-8").splitlines()
+ begin ,end = line_list.index("# constraints begin") ,line_list.index("# constraints end")
+ text = "\n".join(line_list[:begin + 1] + ["" ,constraint.strip("\n") ,""] + line_list[end:]) + "\n"
+ guard_fpath.unlink()
+ guard_fpath.write_text(text ,encoding="utf-8")
+ os.chmod(guard_fpath ,0o644)
+
+def write_constraint(tree ,work_dpath):
+ "the test's constraints in its copy of guard_smoke.py, allowing the files in ALLOWED to the person running the test"
+ person = getpass.getuser()
+ allowed = {(str(work_dpath / d) ,n): (m ,int(p ,8)) for d ,n ,m ,p in ALLOWED}
+ text = (
+ "# written by the smoke test\n\n"
+ f"PERSON = {person!r}\n"
+ f"ALLOWED = {allowed!r}\n\n"
+ "def is_allowed_account(user_name):\n"
+ " return user_name == PERSON\n\n"
+ "def is_allowed_open(directory ,name ,mode ,owner ,permission):\n"
+ " entry = ALLOWED.get((directory ,name))\n"
+ " return entry is not None and mode in entry[0] and owner == PERSON and permission == entry[1]\n\n"
+ "def is_allowed_unlink(directory ,name):\n"
+ " entry = ALLOWED.get((directory ,name))\n"
+ " return entry is not None and \"unlink\" in entry[0]\n"
+ )
+ put_constraint(tree / "Man_In_Grey" / "guard_smoke.py" ,text)
+
+def test(repo_home):
+ consumer = repo_home / "consumer"
+ work_dpath = (repo_home / "tester" / "scratchpad" / TEST_NAME).resolve()
+ tree = work_dpath / "tree"
+ program = tree / "Man_In_Grey" / "Man_In_Grey"
+ print(f"run test in {TEST_DPATH}")
+ print(f" program {program}")
+ print(f" writes {work_dpath}")
+ log = Log()
+
+ promoted = os.access(consumer / "Man_In_Grey" / "Man_In_Grey" ,os.X_OK)
+ if not log.check(promoted ,"Man_In_Grey is promoted"
+ ,None if promoted else f"the developer promotes Man_In_Grey and {GATE_NAME} first"):
+ return log.summary()
+
+ if work_dpath.exists():
+ shutil.rmtree(work_dpath)
+ for rel in OUTPUT_DIR:
+ (work_dpath / rel).mkdir(parents=True)
+ make_tree(consumer ,tree)
+ write_constraint(tree ,work_dpath)
+ (work_dpath / "out" / "obsolete.conf").write_text("to be unlinked\n")
+ out = work_dpath / "out"
+
+ # run_1 and run_2
+ for run_name in ("run_1" ,"run_2"):
+ status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer" ,work_dpath)
+ if check_status(log ,f"{run_name} Man_In_Grey exits 0" ,status ,0 ,output):
+ compare(log ,run_name ,work_dpath)
+ else:
+ log.note(f"{run_name}: the files were not compared, since Man_In_Grey failed")
+ return log.summary()
+
+ # create: once, then refused
+ status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer_create" / "new.py" ,work_dpath)
+ check_status(log ,"create Man_In_Grey exits 0" ,status ,0 ,output)
+ new = out / "new.conf"
+ log.check(new.is_file() and new.read_text() == "made once\n" and stat.S_IMODE(new.stat().st_mode) == 0o600
+ ,"create out/new.conf" ,None if new.is_file() else "not written")
+ before = snapshot(out)
+ status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer_create" / "new.py" ,work_dpath)
+ check_status(log ,"create again is refused by the filesystem check" ,status ,1 ,output ,"filesystem_broken:")
+ log.check(snapshot(out) == before ,"create again changes nothing")
+
+ # a journal the constraints refuse
+ before = snapshot(out)
+ status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer_refused" ,work_dpath)
+ check_status(log ,"refused is refused by the constraint check" ,status ,1 ,output ,"constraint_broken:")
+ log.check(snapshot(out) == before ,"refused changes nothing, the allowed file included")
+
+ # a composer with a wrong argument
+ before = snapshot(out)
+ status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer_argument" ,work_dpath)
+ check_status(log ,"argument fails in the composer" ,status ,2 ,output ,"wrong_mode.py: open: mode 'overwrite'")
+ log.check(snapshot(out) == before ,"argument changes nothing")
+
+ # a composer other accounts can write
+ writable = work_dpath / "composer_writable"
+ writable.mkdir()
+ shutil.copy(TEST_DPATH / "composer" / "unbound_conf.py" ,writable / "unbound_conf.py")
+ os.chmod(writable / "unbound_conf.py" ,0o664)
+ before = snapshot(out)
+ status ,output = run_Man_In_Grey(program ,writable ,work_dpath)
+ check_status(log ,"writable composer is refused by the orchestrator" ,status ,2 ,output ,"writable by group or other accounts")
+ log.check(snapshot(out) == before ,"writable changes nothing")
+
+ # check_only
+ before = snapshot(out)
+ status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer" ,work_dpath ,"check_only")
+ check_status(log ,"check_only is approved by the guard" ,status ,0 ,output ,"approved: guard smoke check passed")
+ log.check(snapshot(out) == before ,"check_only changes nothing")
+
+ # a resource limit the gate cannot raise
+ before = snapshot(out)
+ status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer" ,work_dpath ,preexec=limit_file_size)
+ check_status(log ,"limit is refused by the gate" ,status ,2 ,output ,"the file size (bytes) limit is 1048576")
+ log.check(snapshot(out) == before ,"limit changes nothing")
+
+ return log.summary()
+
+def clean(repo_home):
+ work_dpath = repo_home / "tester" / "scratchpad" / TEST_NAME
+ print(f"run clean in {TEST_DPATH}")
+ if work_dpath.exists():
+ shutil.rmtree(work_dpath)
+ print(f" deleted {work_dpath}")
+ else:
+ print(f" kept {work_dpath} (there is nothing to delete)")
+ return 0
+
+
+# --------------------------------------------------------------------------------
+# command line
+
+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()
+ command ,rest = argv[0] ,argv[1:]
+ if command not in ("test" ,"clean"):
+ print(f"Error: unknown command '{command}'." ,file=sys.stderr)
+ print_help()
+ return 1
+ if rest:
+ print(f"Error: {command} is given no arguments." ,file=sys.stderr)
+ return 1
+ if not os.environ.get("REPO_HOME"):
+ print("Error: REPO_HOME is not set; enter the tester role with ./setup tester." ,file=sys.stderr)
+ return 1
+ repo_home = Path(os.environ["REPO_HOME"]).resolve()
+ return test(repo_home) if command == "test" else clean(repo_home)
+
+
+if __name__ == "__main__":
+ try:
+ sys.exit(CLI())
+ except BrokenPipeError:
+ os.dup2(os.open(os.devnull ,os.O_WRONLY) ,sys.stdout.fileno())
+ sys.exit(1)
+++ /dev/null
-# replace: the second run discards the first run's file, keeping no backup.
-# The content is written in two parts.
-
-def compose(prov ,journal_iface):
- file = journal_iface.open("out/net" ,prov.implied_fname ,"replace" ,prov.process_user ,"0444")
- journal_iface.write(file ,"server:\n")
- journal_iface.write(file ," verbosity: 1\n")
+++ /dev/null
-# unlink: the first run removes out/obsolete.conf; the second finds nothing to remove.
-
-def compose(prov ,journal_iface):
- journal_iface.unlink("out" ,"obsolete.conf")
+++ /dev/null
-# replace_keep: the second run keeps the first run's file as a timestamped backup.
-# The file name is the one this composer implies: its own name less .py.
-
-def compose(prov ,journal_iface):
- file = journal_iface.open("out" ,prov.implied_fname ,"replace_keep" ,prov.process_user ,0o444)
- journal_iface.write(file ,"server:\n do-ip6: no\n")
+++ /dev/null
-# replace_keep, under a file name given here rather than implied.
-
-def compose(prov ,journal_iface):
- file = journal_iface.open("out/web" ,"nginx.conf" ,"replace_keep" ,prov.process_user ,"0644")
- journal_iface.write(file ,"events {}\nhttp { server { listen 8080; } }\n")
+++ /dev/null
-# A wrong argument: the journal interface raises an error, and no journal reaches the gate.
-
-def compose(prov ,journal_iface):
- journal_iface.open("out" ,"unbound_conf" ,"overwrite" ,prov.process_user ,0o444)
+++ /dev/null
-# create: allowed once; a second run is refused, as the file then exists.
-
-def compose(prov ,journal_iface):
- file = journal_iface.open("out" ,"new.conf" ,"create" ,prov.process_user ,0o600)
- journal_iface.write(file ,"made once\n")
+++ /dev/null
-# One file the constraints allow, and one they do not. The whole journal is refused.
-
-def compose(prov ,journal_iface):
- allowed = journal_iface.open("out" ,"unbound_conf" ,"replace_keep" ,prov.process_user ,0o444)
- journal_iface.write(allowed ,"this is not to be written\n")
- forbidden = journal_iface.open("out" ,"forbidden.conf" ,"replace" ,prov.process_user ,0o644)
- journal_iface.write(forbidden ,"nor this\n")
+++ /dev/null
-server:
- verbosity: 1
+++ /dev/null
-server:
- do-ip6: no
+++ /dev/null
-events {}
-http { server { listen 8080; } }
+++ /dev/null
-server:
- verbosity: 1
+++ /dev/null
-server:
- do-ip6: no
+++ /dev/null
-server:
- do-ip6: no
+++ /dev/null
-events {}
-http { server { listen 8080; } }
+++ /dev/null
-events {}
-http { server { listen 8080; } }
+++ /dev/null
-#!/usr/bin/env python3
-# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-"""
-run
-
-The write_file test of Man_In_Grey's file instruction language. It copies
-the promoted Man_In_Grey, from $REPO_HOME/consumer, into its scratchpad
-directory, puts constraints of its own at the top of the copy's
-guard_file.py, allowing the files it expects, and runs that copy through
-the file language's gate, not setuid. It checks:
-
- run_1 orchestrate composer/: each composer's file written, and
- out/obsolete.conf, made by the test beforehand, unlinked; the
- files are compared with expected/run_1
- run_2 orchestrate composer/ again: replace_keep files kept as backups named
- <file>.YYYYMMDDTHHMMSSZ, the replace file overwritten with no
- backup; compared with expected/run_2
- create orchestrate composer_create/new.py: a file opened in create mode is written
- once, and a second run is refused by the guard's filesystem check
- refused orchestrate composer_refused/: a journal opening one allowed file and one the
- constraints do not allow is refused whole by the guard's constraint check
- argument orchestrate composer_argument/: a composer giving a wrong mode fails when it
- places the instruction, and no journal reaches the gate
- writable orchestrate a composer directory holding a composer other accounts can write:
- the orchestrator refuses to run it
- check_only orchestrate composer/ with check_only: the guard approves the journal, and the
- interpreter is not run
- limit orchestrate composer/ with a file size limit the gate cannot raise: the gate
- refuses before starting the guard
-
-A refused run is to leave every file as it was. Backup names carry the time
-of the run, so names are compared with the time replaced by the letters
-YYYYMMDDTHHMMSSZ. Content, mode and owner are compared too.
-
-The test reads consumer/ and writes only in its scratchpad directory,
-$REPO_HOME/tester/scratchpad/write_file, where it runs its copy of
-Man_In_Grey from tree/.
-
-Usage: run <command>
- help | -h | --help print this message (also printed when given no arguments)
- test run the test; the exit status is 0 only when every check passes
- clean delete the test's scratchpad directory
-"""
-
-import sys ,os ,re ,stat ,json ,shutil ,subprocess ,getpass ,resource
-from pathlib import Path
-
-TEST_NAME = "write_file"
-TEST_DPATH = Path(__file__).resolve().parent
-
-OUTPUT_DIR = ("out" ,"out/net" ,"out/web")
-
-# the files the constraints allow: (directory under the scratchpad, name, modes, permission)
-ALLOWED = (
- ("out" ,"unbound_conf" ,["replace_keep"] ,"0444")
- ,("out/net" ,"unbound.conf" ,["replace"] ,"0444")
- ,("out/web" ,"nginx.conf" ,["replace_keep"] ,"0644")
- ,("out" ,"obsolete.conf" ,["unlink"] ,"0644")
- ,("out" ,"new.conf" ,["create"] ,"0600")
-)
-
-MODE = {
- "run_1": {
- "out/unbound_conf": 0o444
- ,"out/net/unbound.conf": 0o444
- ,"out/web/nginx.conf": 0o644
- },
- "run_2": {
- "out/unbound_conf": 0o444
- ,"out/unbound_conf.YYYYMMDDTHHMMSSZ": 0o444
- ,"out/net/unbound.conf": 0o444
- ,"out/web/nginx.conf": 0o644
- ,"out/web/nginx.conf.YYYYMMDDTHHMMSSZ": 0o644
- },
-}
-
-TIME_SUFFIX = re.compile(r"\.\d{8}T\d{6}Z(\.\d+)?$")
-TIME_PLACEHOLDER = ".YYYYMMDDTHHMMSSZ"
-
-
-# --------------------------------------------------------------------------------
-# the log
-
-TAG_WIDTH = 10
-
-class Log:
- def __init__(self):
- self.count = {"passed": 0 ,"failed": 0}
-
- def check(self ,passed ,what ,detail=None):
- tag = "passed" if passed else "failed"
- self.count[tag] += 1
- print(f" {tag:<{TAG_WIDTH}}{what}" + (f" ({detail})" if detail else ""))
- return passed
-
- def note(self ,text):
- for line in text.splitlines():
- print(f" {'':<{TAG_WIDTH}}{line}")
-
- def summary(self):
- print("summary:")
- print(f" {self.count['passed']:>4} checks passed")
- print(f" {self.count['failed']:>4} checks failed")
- return 0 if self.count["failed"] == 0 else 1
-
-
-# --------------------------------------------------------------------------------
-# the work
-
-def tree(root):
- """
- The files under root as {name: path}, the name relative to root with any
- backup time replaced by the placeholder; a name met twice is listed with
- a count, so two backups of one file are seen.
- """
- result = {}
- for path in sorted(p for p in root.rglob("*") if p.is_file() or p.is_symlink()):
- name = TIME_SUFFIX.sub(TIME_PLACEHOLDER ,path.relative_to(root).as_posix())
- if name in result:
- n = 2
- while f"{name} (copy {n})" in result: n += 1
- name = f"{name} (copy {n})"
- result[name] = path
- return result
-
-def snapshot(root):
- "every file under root, by its exact name, with its content, mode and owner"
- result = {}
- for path in sorted(p for p in root.rglob("*") if p.is_file()):
- st = path.lstat()
- result[path.relative_to(root).as_posix()] = (path.read_bytes() ,stat.S_IMODE(st.st_mode) ,st.st_uid)
- return result
-
-def compare(log ,run_name ,work_dpath):
- expected = tree(TEST_DPATH / "expected" / run_name)
- found = {n: p for n ,p in tree(work_dpath).items() if n.startswith("out/")}
- for name in sorted(set(expected) | set(found)):
- what = f"{run_name} {name}"
- if name not in found:
- log.check(False ,what ,"expected, not written"); continue
- if name not in expected:
- log.check(False ,what ,"written, not expected"); continue
- path = found[name]
- problem = []
- if path.read_bytes() != expected[name].read_bytes():
- problem.append(f"content differs from expected/{run_name}/{name}")
- mode = stat.S_IMODE(path.lstat().st_mode)
- want = MODE[run_name].get(name)
- if want is not None and mode != want:
- problem.append(f"mode {mode:04o}, expected {want:04o}")
- if path.lstat().st_uid != os.getuid():
- problem.append(f"owner uid {path.lstat().st_uid}, expected {os.getuid()}")
- log.check(not problem ,what ,"; ".join(problem) or None)
-
-def limit_file_size():
- "run in the child before Man_In_Grey starts: a file size limit, soft and hard, of 1 MiB"
- resource.setrlimit(resource.RLIMIT_FSIZE ,(1 << 20 ,1 << 20))
-
-def run_Man_In_Grey(program ,path ,work_dpath ,*option ,preexec=None):
- command = [str(program) ,"orchestrate" ,str(path) ,*option]
- result = subprocess.run(command ,cwd=work_dpath ,capture_output=True ,text=True ,preexec_fn=preexec)
- return result.returncode ,(result.stdout + result.stderr).strip()
-
-def check_status(log ,what ,status ,want ,output ,want_text=None):
- passed = status == want and (want_text is None or want_text in output)
- detail = None
- if status != want:
- detail = f"exit status {status}, expected {want}"
- elif not passed:
- detail = f"the output does not say {want_text!r}"
- log.check(passed ,what ,detail)
- if not passed and output:
- log.note(output)
- return passed
-
-def make_tree(consumer ,tree):
- """
- A copy of the promoted Man_In_Grey/ and Man_In_Grey_gate/, side by side, in
- tree. The gate refuses a guard in a directory other accounts can write,
- so tree and the directory holding it are made writable by the owner alone.
- """
- tree.mkdir(parents=True)
- for each in (tree.parent ,tree):
- os.chmod(each ,0o755)
- for name in ("Man_In_Grey" ,"Man_In_Grey_gate"):
- (tree / name).mkdir()
- os.chmod(tree / name ,0o755)
- for source in sorted((consumer / name).iterdir()):
- if source.is_file():
- shutil.copy2(source ,tree / name / source.name)
-
-def put_constraint(guard_fpath ,constraint):
- "replace the predicates between the guard's constraint marks with constraint"
- line_list = guard_fpath.read_text(encoding="utf-8").splitlines()
- begin ,end = line_list.index("# constraints begin") ,line_list.index("# constraints end")
- text = "\n".join(line_list[:begin + 1] + ["" ,constraint.strip("\n") ,""] + line_list[end:]) + "\n"
- guard_fpath.unlink()
- guard_fpath.write_text(text ,encoding="utf-8")
- os.chmod(guard_fpath ,0o644)
-
-def write_constraint(tree ,work_dpath):
- "the test's constraints in its copy of guard_file.py, allowing the files in ALLOWED to the person running the test"
- person = getpass.getuser()
- allowed = {(str(work_dpath / d) ,n): (m ,int(p ,8)) for d ,n ,m ,p in ALLOWED}
- text = (
- "# written by the write_file test\n\n"
- f"PERSON = {person!r}\n"
- f"ALLOWED = {allowed!r}\n\n"
- "def is_allowed_account(user_name):\n"
- " return user_name == PERSON\n\n"
- "def is_allowed_open(directory ,name ,mode ,owner ,permission):\n"
- " entry = ALLOWED.get((directory ,name))\n"
- " return entry is not None and mode in entry[0] and owner == PERSON and permission == entry[1]\n\n"
- "def is_allowed_unlink(directory ,name):\n"
- " entry = ALLOWED.get((directory ,name))\n"
- " return entry is not None and \"unlink\" in entry[0]\n"
- )
- put_constraint(tree / "Man_In_Grey" / "guard_file.py" ,text)
-
-def test(repo_home):
- consumer = repo_home / "consumer"
- work_dpath = (repo_home / "tester" / "scratchpad" / TEST_NAME).resolve()
- tree = work_dpath / "tree"
- program = tree / "Man_In_Grey" / "Man_In_Grey"
- print(f"run test in {TEST_DPATH}")
- print(f" program {program}")
- print(f" writes {work_dpath}")
- log = Log()
-
- promoted = os.access(consumer / "Man_In_Grey" / "Man_In_Grey" ,os.X_OK)
- if not log.check(promoted ,"Man_In_Grey is promoted"
- ,None if promoted else "the developer promotes Man_In_Grey and Man_In_Grey_file_gate first"):
- return log.summary()
-
- if work_dpath.exists():
- shutil.rmtree(work_dpath)
- for rel in OUTPUT_DIR:
- (work_dpath / rel).mkdir(parents=True)
- make_tree(consumer ,tree)
- write_constraint(tree ,work_dpath)
- (work_dpath / "out" / "obsolete.conf").write_text("to be unlinked\n")
- out = work_dpath / "out"
-
- # run_1 and run_2
- for run_name in ("run_1" ,"run_2"):
- status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer" ,work_dpath)
- if check_status(log ,f"{run_name} Man_In_Grey exits 0" ,status ,0 ,output):
- compare(log ,run_name ,work_dpath)
- else:
- log.note(f"{run_name}: the files were not compared, since Man_In_Grey failed")
- return log.summary()
-
- # create: once, then refused
- status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer_create" / "new.py" ,work_dpath)
- check_status(log ,"create Man_In_Grey exits 0" ,status ,0 ,output)
- new = out / "new.conf"
- log.check(new.is_file() and new.read_text() == "made once\n" and stat.S_IMODE(new.stat().st_mode) == 0o600
- ,"create out/new.conf" ,None if new.is_file() else "not written")
- before = snapshot(out)
- status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer_create" / "new.py" ,work_dpath)
- check_status(log ,"create again is refused by the filesystem check" ,status ,1 ,output ,"filesystem_broken:")
- log.check(snapshot(out) == before ,"create again changes nothing")
-
- # a journal the constraints refuse
- before = snapshot(out)
- status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer_refused" ,work_dpath)
- check_status(log ,"refused is refused by the constraint check" ,status ,1 ,output ,"constraint_broken:")
- log.check(snapshot(out) == before ,"refused changes nothing, the allowed file included")
-
- # a composer with a wrong argument
- before = snapshot(out)
- status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer_argument" ,work_dpath)
- check_status(log ,"argument fails in the composer" ,status ,2 ,output ,"wrong_mode.py: open: mode 'overwrite'")
- log.check(snapshot(out) == before ,"argument changes nothing")
-
- # a composer other accounts can write
- writable = work_dpath / "composer_writable"
- writable.mkdir()
- shutil.copy(TEST_DPATH / "composer" / "unbound_conf.py" ,writable / "unbound_conf.py")
- os.chmod(writable / "unbound_conf.py" ,0o664)
- before = snapshot(out)
- status ,output = run_Man_In_Grey(program ,writable ,work_dpath)
- check_status(log ,"writable composer is refused by the orchestrator" ,status ,2 ,output ,"writable by group or other accounts")
- log.check(snapshot(out) == before ,"writable changes nothing")
-
- # check_only
- before = snapshot(out)
- status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer" ,work_dpath ,"check_only")
- check_status(log ,"check_only is approved by the guard" ,status ,0 ,output ,"approved: guard file check passed")
- log.check(snapshot(out) == before ,"check_only changes nothing")
-
- # a resource limit the gate cannot raise
- before = snapshot(out)
- status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer" ,work_dpath ,preexec=limit_file_size)
- check_status(log ,"limit is refused by the gate" ,status ,2 ,output ,"the file size (bytes) limit is 1048576")
- log.check(snapshot(out) == before ,"limit changes nothing")
-
- return log.summary()
-
-def clean(repo_home):
- work_dpath = repo_home / "tester" / "scratchpad" / TEST_NAME
- print(f"run clean in {TEST_DPATH}")
- if work_dpath.exists():
- shutil.rmtree(work_dpath)
- print(f" deleted {work_dpath}")
- else:
- print(f" kept {work_dpath} (there is nothing to delete)")
- return 0
-
-
-# --------------------------------------------------------------------------------
-# command line
-
-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()
- command ,rest = argv[0] ,argv[1:]
- if command not in ("test" ,"clean"):
- print(f"Error: unknown command '{command}'." ,file=sys.stderr)
- print_help()
- return 1
- if rest:
- print(f"Error: {command} is given no arguments." ,file=sys.stderr)
- return 1
- if not os.environ.get("REPO_HOME"):
- print("Error: REPO_HOME is not set; enter the tester role with ./setup tester." ,file=sys.stderr)
- return 1
- repo_home = Path(os.environ["REPO_HOME"]).resolve()
- return test(repo_home) if command == "test" else clean(repo_home)
-
-
-if __name__ == "__main__":
- try:
- sys.exit(CLI())
- except BrokenPipeError:
- os.dup2(os.open(os.devnull ,os.O_WRONLY) ,sys.stdout.fileno())
- sys.exit(1)