From: Thomas Walker Lynch
Date: Fri, 6 Nov 2026 13:44:49 +0000 (+0000)
Subject: gate logs
X-Git-Url: https://git.reasoningtechnology.com/%27%20%20%20window.RT.dirpr_library%20%20%20%27/%5B%5E?a=commitdiff_plain;h=28ee9e7fb0692953a5b9c9b896263b39610b2f7d;p=Man_In_Grey
gate logs
---
diff --git a/developer/authored/Man_In_Grey.copy/Constraint.py b/developer/authored/Man_In_Grey.copy/Constraint.py
new file mode 100644
index 0000000..9219286
--- /dev/null
+++ b/developer/authored/Man_In_Grey.copy/Constraint.py
@@ -0,0 +1,63 @@
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+Constraint - predicates that recur among guards' constraints.
+
+A guard's constraints are written at the top of the guard itself, as
+predicates such as is_allowed_account(user_name). This library holds the
+predicates many guards would otherwise each write: membership of a group,
+a path within a directory, a permission within a limit. A guard imports it
+after its ownership check, as it imports its other modules, and its
+constraints call it by name, as Constraint.is_group_member(user_name ,"bio").
+"""
+
+import grp ,os ,pwd
+
+
+def is_group_member(user_name ,group_name):
+ """
+ True when the account is a member of the group by the group database:
+ listed in /etc/group as a member, or having the group as its primary group.
+ """
+ try:
+ group = grp.getgrnam(group_name)
+ except KeyError:
+ return False
+ if user_name in group.gr_mem:
+ return True
+ try:
+ return pwd.getpwnam(user_name).pw_gid == group.gr_gid
+ except KeyError:
+ return False
+
+def has_real_group(group_name):
+ """
+ True when the gate's real gid or supplementary groups include the group.
+ These are what the program that invoked the gate held, which the kernel
+ passes on: a session started before the account joined the group does not
+ hold it, though is_group_member already says yes.
+ """
+ try:
+ gid = grp.getgrnam(group_name).gr_gid
+ except KeyError:
+ return False
+ return gid == os.getgid() or gid in os.getgroups()
+
+def is_path_under(path ,directory):
+ """
+ True when path is directory or lies within it. Both are to be absolute and
+ in normal form, as the guard's wellformed check makes a journal's paths.
+ """
+ if not (isinstance(path ,str) and isinstance(directory ,str)):
+ return False
+ if not (path.startswith("/") and directory.startswith("/")):
+ return False
+ if os.path.normpath(path) != path or os.path.normpath(directory) != directory:
+ return False
+ return path == directory or path.startswith(directory.rstrip("/") + "/")
+
+def has_permission_within(permission ,limit):
+ """
+ True when permission sets no bit that limit does not: 0o644 and 0o600 are
+ within 0o644, and 0o4755, setuid, is within neither 0o644 nor 0o755.
+ """
+ return isinstance(permission ,int) and not isinstance(permission ,bool) and permission & ~limit == 0
diff --git a/developer/authored/Man_In_Grey.copy/EmplaceBioLanguage.py b/developer/authored/Man_In_Grey.copy/EmplaceBioLanguage.py
new file mode 100644
index 0000000..e0db6cc
--- /dev/null
+++ b/developer/authored/Man_In_Grey.copy/EmplaceBioLanguage.py
@@ -0,0 +1,361 @@
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+EmplaceBioLanguage - the emplace_bio instruction language: a bio page.
+
+A host publishes one bio page for each permitted account, as
+.html in a directory its web server serves, with the picture
+beside it as .png or .jpg. The web server's account
+owns the directory, so a person cannot write there. He gives his page's
+display name, text and picture as instructions, and the emplace_bio gate,
+owned by the web server's account, places the page.
+
+A journal names no file. The file names come from the user name of the
+real uid, which the kernel gives the interpreter, and the directory comes
+from page_directory() at the top of the guard. The interpreter writes the
+HTML itself, from a fixed form, and escapes every text it is given, so
+nothing a person gives becomes markup. Decoration travels out of band, as a
+list of styles beside each run of text, and each style becomes one fixed
+tag with no attribute.
+
+ display_name(text) the page's title and heading; required, once
+ paragraph() starts a paragraph
+ text(content ,style) a run of text in the current paragraph, with a list
+ of styles drawn from STYLE; an empty list is plain text
+ picture(content) a PNG or JPEG image; at most once
+
+The journal describes the whole page: a run that gives no picture removes
+the picture an earlier run placed.
+
+The module has two halves. The composer half is JournalInterface, which
+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_emplace_bio.py: is_allowed_account, is_allowed_text_size and
+is_allowed_picture_size, and page_directory(), where the pages go.
+"""
+
+import html ,os ,pwd ,stat ,urllib.parse
+
+from Journal import JournalError ,has_control ,clean
+
+NAME = "emplace_bio"
+
+# each style and its tag, in the order the tags nest, outermost first
+STYLE = {
+ "strong": "strong"
+ ,"emphasis": "em"
+ ,"underline": "u"
+ ,"strikethrough": "s"
+}
+
+ARGUMENT = {
+ "display_name": ("text" ,)
+ ,"paragraph": ()
+ ,"text": ("content" ,"style")
+ ,"picture": ("content" ,)
+}
+
+DISPLAY_NAME_MAX = 200 # characters
+
+SIGNATURE = (
+ (b"\x89PNG\r\n\x1a\n" ,".png")
+ ,(b"\xff\xd8\xff" ,".jpg")
+)
+
+PAGE_PERMISSION = 0o644
+
+
+# --------------------------------------------------------------------------------
+# argument checks, shared by both halves
+
+def display_name_problem(value):
+ if not isinstance(value ,str) or not value.strip():
+ return "the display name is to be text that is not empty"
+ if has_control(value):
+ return f"the display name {clean(value)} holds a control character"
+ if len(value) > DISPLAY_NAME_MAX:
+ return f"the display name is longer than {DISPLAY_NAME_MAX} characters"
+ return None
+
+def content_problem(value):
+ if not isinstance(value ,str) or not value:
+ return "content is to be text that is not empty"
+ if has_control(value):
+ return f"content {clean(value)} holds a control character; a new paragraph is the instruction paragraph()"
+ return None
+
+def style_problem(value):
+ if not isinstance(value ,list) or not all(isinstance(s ,str) for s in value):
+ return "style is to be a list of style names"
+ for s in value:
+ if s not in STYLE:
+ return f"style {clean(repr(s))} is not one of {', '.join(STYLE)}"
+ if len(set(value)) != len(value):
+ return "style names a style twice"
+ return None
+
+def picture_extension(content):
+ "the extension for the picture's format, or None when it is neither PNG nor JPEG"
+ for signature ,extension in SIGNATURE:
+ if content.startswith(signature):
+ return extension
+ return None
+
+
+# --------------------------------------------------------------------------------
+# composer half
+
+class JournalInterface:
+ """
+ The journal interface of the emplace_bio language. A composer is given
+ one, and places instructions with its four functions.
+ """
+
+ def __init__(self ,journal ,working_dpath):
+ self._journal = journal
+ self._in_paragraph = False
+
+ def display_name(self ,text):
+ problem = display_name_problem(text)
+ if problem: raise JournalError(f"display_name: {problem}")
+ self._journal.place("display_name" ,{"text": text})
+
+ def paragraph(self):
+ self._journal.place("paragraph" ,{})
+ self._in_paragraph = True
+
+ def text(self ,content ,style=()):
+ if not self._in_paragraph:
+ raise JournalError("text: no paragraph has been started; place paragraph() first")
+ problem = content_problem(content)
+ if problem: raise JournalError(f"text: {problem}")
+ if isinstance(style ,str) or not hasattr(style ,"__iter__"):
+ raise JournalError("text: style is to be a list of style names")
+ style = list(style)
+ problem = style_problem(style)
+ if problem: raise JournalError(f"text: {problem}")
+ style = [s for s in STYLE if s in style]
+ self._journal.place("text" ,{"content": content ,"style": style})
+
+ def picture(self ,content):
+ if not isinstance(content ,(bytes ,bytearray)):
+ raise JournalError("picture: content is to be the bytes of a PNG or JPEG file")
+ content = bytes(content)
+ if picture_extension(content) is None:
+ raise JournalError("picture: content is neither a PNG nor a JPEG file")
+ self._journal.place("picture" ,{"content": content})
+
+
+# --------------------------------------------------------------------------------
+# interpreter half: the guard's checks
+
+def check_wellformed(data):
+ problem_list = []
+ count = {name: 0 for name in ARGUMENT}
+ in_paragraph = False
+ run_count = 0
+ 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
+ # the order of the instructions, checked whatever their arguments
+ count[name] += 1
+ if name == "paragraph":
+ if in_paragraph and run_count == 0:
+ problem_list.append(f"@{index} paragraph: the paragraph before it holds no text")
+ in_paragraph ,run_count = True ,0
+ elif name == "text":
+ if not in_paragraph:
+ problem_list.append(f"@{index} text: no paragraph has been started")
+ run_count += 1
+ if set(argument) != set(ARGUMENT[name]):
+ problem_list.append(f"@{index} {name}: arguments {clean(sorted(argument))}, expected {sorted(ARGUMENT[name])}"); continue
+ if name == "display_name":
+ problem = display_name_problem(argument["text"])
+ if problem: problem_list.append(f"@{index} display_name: {problem}")
+ elif name == "text":
+ for problem in (content_problem(argument["content"]) ,style_problem(argument["style"])):
+ if problem: problem_list.append(f"@{index} text: {problem}")
+ elif name == "picture":
+ if not isinstance(argument["content"] ,(bytes ,bytearray)) or not argument["content"]:
+ problem_list.append(f"@{index} picture: content is not bytes")
+ if count["display_name"] != 1:
+ problem_list.append(f"the journal gives the display name {count['display_name']} times; it is given once")
+ if count["paragraph"] == 0:
+ problem_list.append("the journal has no paragraph")
+ elif run_count == 0:
+ problem_list.append("the last paragraph holds no text")
+ if count["picture"] > 1:
+ problem_list.append(f"the journal gives {count['picture']} pictures; it gives at most one")
+ return problem_list
+
+def page_directory_problem(directory):
+ if not isinstance(directory ,str) or not directory.startswith("/") or os.path.normpath(directory) != directory:
+ return f"page_directory() gives {clean(repr(directory))}, not an absolute directory in normal form"
+ return None
+
+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 = []
+ problem = page_directory_problem(constraint.page_directory())
+ if problem: problem_list.append(problem)
+ text_bytes = 0
+ for instruction in data["instruction_list"]:
+ name ,argument = instruction["name"] ,instruction["argument"]
+ if name == "display_name":
+ text_bytes += len(argument["text"].encode("utf-8"))
+ elif name == "text":
+ text_bytes += len(argument["content"].encode("utf-8"))
+ elif name == "picture" and constraint.is_allowed_picture_size(len(argument["content"])) is not True:
+ problem_list.append(f"the picture is {len(argument['content'])} bytes, which is not allowed")
+ if constraint.is_allowed_text_size(text_bytes) is not True:
+ problem_list.append(f"the text is {text_bytes} bytes, which is not allowed")
+ return problem_list
+
+def check_content(data):
+ problem_list = []
+ for index ,instruction in enumerate(data["instruction_list"]):
+ if instruction["name"] == "picture" and picture_extension(instruction["argument"]["content"]) is None:
+ problem_list.append(f"@{index} picture: the content is neither a PNG nor a JPEG file")
+ return problem_list
+
+def page_name_list(user_name):
+ "the files a page of this account can occupy"
+ return [user_name + ".html"] + [user_name + extension for _ ,extension in SIGNATURE]
+
+def check_filesystem(data ,constraint ,gate_invoking_account):
+ problem_list = []
+ if has_control(gate_invoking_account) or gate_invoking_account.startswith("."):
+ return [f"the account name {clean(repr(gate_invoking_account))} cannot name a page"]
+ directory = constraint.page_directory()
+ if not os.path.isdir(directory):
+ return [f"{directory} is not a directory"]
+ if os.path.realpath(directory) != directory:
+ return [f"{directory} passes through a symbolic link"]
+ # access() alone answers for the real uid; effective_ids asks for the effective uid, which writes
+ if not os.access(directory ,os.W_OK | os.X_OK ,effective_ids=os.access in os.supports_effective_ids):
+ problem_list.append(f"{directory} cannot be written by the effective uid's account")
+ for name in page_name_list(gate_invoking_account):
+ try:
+ st = os.lstat(os.path.join(directory ,name))
+ except FileNotFoundError:
+ continue
+ if not stat.S_ISREG(st.st_mode):
+ problem_list.append(f"{directory}/{name} exists and is not a regular file")
+ return problem_list
+
+CHECK = (
+ ("wellformed" ,lambda data ,constraint ,gate_invoking_account: check_wellformed(data))
+ ,("constraint" ,check_constraint)
+ ,("content" ,lambda data ,constraint ,gate_invoking_account: check_content(data))
+ ,("filesystem" ,check_filesystem)
+)
+
+
+# --------------------------------------------------------------------------------
+# interpreter half: the page
+
+def page(display_name ,paragraph_list ,picture_name):
+ """
+ The HTML of a page. Every text given is escaped; every tag comes from this
+ function or from STYLE.
+ """
+ name = html.escape(display_name ,quote=True)
+ part = [
+ "\n"
+ ,"\n\n"
+ ,'\n'
+ ,'\n'
+ ,'\n'
+ ,"" ,name ,"\n"
+ ,"\n\n"
+ ,"
" ,name ,"
\n"
+ ]
+ if picture_name:
+ part += ['\n']
+ for run_list in paragraph_list:
+ part.append("
")
+ for content ,style in run_list:
+ tag_list = [STYLE[s] for s in STYLE if s in style]
+ part += [f"<{tag}>" for tag in tag_list]
+ part.append(html.escape(content ,quote=False))
+ part += [f"{tag}>" for tag in reversed(tag_list)]
+ part.append("
\n")
+ part.append("\n\n")
+ return "".join(part)
+
+def _write_temporary(dirfd ,target ,content):
+ tmp = f".{target}.Man_In_Grey.{os.getpid()}"
+ fd = os.open(tmp ,os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW ,0o600 ,dir_fd=dirfd)
+ try:
+ view = memoryview(content)
+ while view:
+ view = view[os.write(fd ,view):]
+ os.fchmod(fd ,PAGE_PERMISSION)
+ os.fsync(fd)
+ finally:
+ os.close(fd)
+ return tmp
+
+def execute(data ,log ,constraint):
+ """
+ Write the picture and the page to temporary files in the directory, then
+ rename each into place, and remove a picture the page no longer shows.
+ log is given a tag and a message for each thing done. Returns 0, or 1 on
+ an error, removing every temporary file not yet put in place.
+ """
+ user_name = pwd.getpwuid(os.getuid()).pw_name
+ directory = constraint.page_directory()
+ display_name ,paragraph_list ,picture = None ,[] ,None
+ for instruction in data["instruction_list"]:
+ name ,argument = instruction["name"] ,instruction["argument"]
+ if name == "display_name":
+ display_name = argument["text"]
+ elif name == "paragraph":
+ paragraph_list.append([])
+ elif name == "text":
+ paragraph_list[-1].append((argument["content"] ,argument["style"]))
+ elif name == "picture":
+ picture = argument["content"]
+ picture_name = user_name + picture_extension(picture) if picture is not None else None
+ page_name = user_name + ".html"
+
+ dirfd = None
+ pending = [] # (temporary ,target)
+ try:
+ dirfd = os.open(directory ,os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
+ if picture_name:
+ pending.append((_write_temporary(dirfd ,picture_name ,picture) ,picture_name))
+ pending.append((_write_temporary(dirfd ,page_name ,page(display_name ,paragraph_list ,picture_name).encode("utf-8")) ,page_name))
+ while pending:
+ tmp ,target = pending[0]
+ os.rename(tmp ,target ,src_dir_fd=dirfd ,dst_dir_fd=dirfd)
+ pending.pop(0)
+ log("emplaced" ,f"{directory}/{target}")
+ for name in page_name_list(user_name)[1:]:
+ if name == picture_name:
+ continue
+ try:
+ st = os.lstat(name ,dir_fd=dirfd)
+ except FileNotFoundError:
+ continue
+ if stat.S_ISREG(st.st_mode):
+ os.unlink(name ,dir_fd=dirfd)
+ log("removed" ,f"{directory}/{name}")
+ os.fsync(dirfd)
+ return 0
+ except OSError as error:
+ log("execution_failed" ,str(error))
+ for tmp ,_ in pending:
+ try:
+ os.unlink(tmp ,dir_fd=dirfd)
+ except OSError:
+ pass
+ return 1
+ finally:
+ if dirfd is not None:
+ os.close(dirfd)
diff --git a/developer/authored/Man_In_Grey.copy/FileLanguage.py b/developer/authored/Man_In_Grey.copy/FileLanguage.py
index 3802c92..14d9344 100644
--- a/developer/authored/Man_In_Grey.copy/FileLanguage.py
+++ b/developer/authored/Man_In_Grey.copy/FileLanguage.py
@@ -23,12 +23,8 @@ 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.
-Constraints are a map:
-
- gate_invoking_account account names allowed to invoke the gate with a journal
- file entries {directory ,name ,mode ,owner ,permission}: the files
- the language allows; mode lists the open modes allowed, and
- "unlink" when unlink is allowed
+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
@@ -168,30 +164,19 @@ def check_wellformed(data):
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 = []
- if gate_invoking_account not in constraint.get("gate_invoking_account" ,()):
- return [f"gate invoking account {gate_invoking_account!r} is not allowed to give journals to this gate"]
- allowed = {}
- for entry in constraint.get("file" ,()):
- allowed[(entry["directory"] ,entry["name"])] = entry
for index ,instruction in enumerate(data["instruction_list"]):
name ,argument = instruction["name"] ,instruction["argument"]
- if name not in ("open" ,"unlink"):
- continue
- where = f"{argument['directory']}/{argument['name']}"
- entry = allowed.get((argument["directory"] ,argument["name"]))
- if entry is None:
- problem_list.append(f"@{index} {name}: {where} is not a file the constraints allow"); continue
- if name == "unlink":
- if "unlink" not in entry.get("mode" ,()):
- problem_list.append(f"@{index} unlink: {where} is not allowed to be unlinked")
- continue
- if argument["mode"] not in entry.get("mode" ,()):
- problem_list.append(f"@{index} open: {where} is not allowed in mode {argument['mode']}")
- if argument["owner"] != entry.get("owner"):
- problem_list.append(f"@{index} open: {where} is to be owned by {entry.get('owner')!r}, not {argument['owner']!r}")
- if argument["permission"] != permission_value(entry.get("permission")):
- problem_list.append(f"@{index} open: {where} is to have permission {entry.get('permission')}, not {argument['permission']:04o}")
+ 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):
@@ -264,12 +249,12 @@ def _backup_name(dirfd ,name):
return candidate
candidate ,n = f"{name}.{stamp}.{n}" ,n + 1
-def execute(data ,log):
+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.
+ an error. The file language needs nothing from its constraint module to execute.
"""
opened = {} # symbol index -> {dirfd ,fd ,tmp ,argument}
try:
diff --git a/developer/authored/Man_In_Grey.copy/Guard.py b/developer/authored/Man_In_Grey.copy/Guard.py
index c980660..ec2d5bc 100644
--- a/developer/authored/Man_In_Grey.copy/Guard.py
+++ b/developer/authored/Man_In_Grey.copy/Guard.py
@@ -9,7 +9,7 @@ given to report as a tag and a message: _broken for each problem,
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 writes the verdict; see guard_file.py and guard_emplace_bio.py.
"""
import Journal
@@ -17,8 +17,9 @@ import Journal
def check(journal_bytes ,language ,constraint ,gate_invoking_account ,report):
"""
- Given the encoded journal, the instruction language module, the constraints,
- the gate invoking account's name, and report(tag ,message). Returns 0 when
+ Given the encoded journal, the instruction language module, its constraint
+ module of predicates, the gate invoking account's name, and report(tag
+ ,message). Returns 0 when
every check passed, 1 when a check found problems, 2 when the journal
could not be read.
"""
diff --git a/developer/authored/Man_In_Grey.copy/Interpreter.py b/developer/authored/Man_In_Grey.copy/Interpreter.py
index c76e11a..e1c95b0 100644
--- a/developer/authored/Man_In_Grey.copy/Interpreter.py
+++ b/developer/authored/Man_In_Grey.copy/Interpreter.py
@@ -8,17 +8,19 @@ the journal is well formed, since it decodes the journal anyway, so that a
malformed journal never reaches execution.
This module is a library. An instruction language's interpreter program calls
-interpret(); see interpreter_file.py.
+interpret(); see interpreter_file.py and interpreter_emplace_bio.py.
"""
import Journal
-def interpret(journal_bytes ,language ,log):
+def interpret(journal_bytes ,language ,log ,constraint):
"""
- Given the encoded journal, the instruction language module, and log(tag
- ,message). Returns 0 when the journal was executed, 1 when execution
- failed, 2 when the journal could not be read or is not well formed.
+ Given the encoded journal, the instruction language module, log(tag
+ ,message), and the constraint module, which the language reads for what
+ execution needs. Returns 0 when the journal was executed, 1 when
+ execution failed, 2 when the journal could not be read or is not well
+ formed.
"""
try:
data = Journal.decode(journal_bytes)
@@ -33,4 +35,4 @@ def interpret(journal_bytes ,language ,log):
log("wellformed_broken" ,problem)
if problem_list:
return 2
- return language.execute(data ,log)
+ return language.execute(data ,log ,constraint)
diff --git a/developer/authored/Man_In_Grey.copy/Journal.py b/developer/authored/Man_In_Grey.copy/Journal.py
index 348441c..fd93f7f 100644
--- a/developer/authored/Man_In_Grey.copy/Journal.py
+++ b/developer/authored/Man_In_Grey.copy/Journal.py
@@ -156,4 +156,4 @@ def instruction_line(index ,instruction):
else:
part.append(f"{name}={value!r}")
composer = instruction.get("composer")
- return f"@{index:<3} {instruction['name']:<8} {' '.join(part)}" + (f" ({composer})" if composer else "")
+ return f"@{index:<3} {instruction['name']:<12} {' '.join(part)}" + (f" ({composer})" if composer else "")
diff --git a/developer/authored/Man_In_Grey.copy/Man_In_Grey.py b/developer/authored/Man_In_Grey.copy/Man_In_Grey.py
index 86453ba..e612eb6 100755
--- a/developer/authored/Man_In_Grey.copy/Man_In_Grey.py
+++ b/developer/authored/Man_In_Grey.copy/Man_In_Grey.py
@@ -9,8 +9,10 @@ the guard, which checks the journal against its constraints, and, when the
guard passes the journal, the interpreter, which executes it.
A composer is a Python file defining compose(prov ,journal_iface). prov
-describes where the composer is; journal_iface places instructions of the file
-instruction language: open, write and unlink.
+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.
Usage: Man_In_Grey [option ...]
help | -h print this message (also printed when given no arguments)
@@ -21,8 +23,6 @@ Usage: Man_In_Grey [option ...]
Options:
gate the gate to invoke; by default
../Man_In_Grey_gate/Man_In_Grey_file_gate
- constraints constraints for a run through a gate that is not setuid,
- given to the guard
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
@@ -39,11 +39,10 @@ a composer failed, or the gate, guard or interpreter could not run.
import sys ,os
sys.dont_write_bytecode = True
-import datetime ,pwd ,runpy ,stat ,subprocess ,traceback
+import datetime ,importlib ,pwd ,re ,runpy ,stat ,subprocess ,traceback
from pathlib import Path
import Journal
-import FileLanguage
# 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/.
@@ -51,7 +50,7 @@ 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" ,"constraints")
+VALUE = ("gate" ,)
COMMAND = ("orchestrate" ,)
@@ -146,13 +145,38 @@ def composer_list(path):
return found
+# --------------------------------------------------------------------------------
+# the gate's language
+
+def gate_flag(gate_path):
+ """The gate's flags as a dict. Raises RuntimeError."""
+ if not os.access(gate_path ,os.X_OK):
+ raise RuntimeError(f"the gate {gate_path} is missing; in the developer role, build and promote it")
+ result = subprocess.run([str(gate_path) ,"flags"] ,capture_output=True ,text=True)
+ if result.returncode != 0:
+ raise RuntimeError(f"the gate {gate_path} did not report its flags")
+ return dict(line.split("=" ,1) for line in result.stdout.splitlines() if "=" in line)
+
+def language_module(name):
+ """The module of an instruction language: emplace_bio is EmplaceBioLanguage.py. Raises RuntimeError."""
+ if not re.fullmatch(r"[a-z][a-z0-9_]*" ,name or ""):
+ raise RuntimeError(f"the gate names the language {name!r}, which names no module")
+ module = "".join(part.capitalize() for part in name.split("_")) + "Language"
+ if not (HERE_DPATH / f"{module}.py").is_file():
+ raise RuntimeError(f"the gate's language is {name}, and {module}.py is not beside {Path(__file__).name}")
+ language = importlib.import_module(module)
+ if getattr(language ,"NAME" ,None) != name:
+ raise RuntimeError(f"{module}.py is not the module of the language {name}")
+ return language
+
+
# --------------------------------------------------------------------------------
# composing, and invoking the gate
-def compose_all(composer_root ,prov_list):
+def compose_all(composer_root ,prov_list ,language):
"""Run each composer into one journal. Raises RuntimeError naming the composer that failed."""
- journal = Journal.Journal(FileLanguage.NAME)
- journal_iface = FileLanguage.JournalInterface(journal ,Path.cwd())
+ journal = Journal.Journal(language.NAME)
+ journal_iface = language.JournalInterface(journal ,Path.cwd())
for prov in prov_list:
rel = prov.composer_rel_fpath.as_posix()
journal.composer = rel
@@ -178,12 +202,14 @@ def compose_all(composer_root ,prov_list):
}
return journal
-def orchestrate(path ,gate ,constraints ,flag):
+def orchestrate(path ,gate ,flag):
path = Path(path).absolute()
+ gate_path = Path(gate).absolute() if gate else DEFAULT_GATE
try:
+ language = language_module(gate_flag(gate_path).get("language"))
prov_list = composer_list(path)
composer_root = path if path.is_dir() else path.parent
- journal = compose_all(composer_root ,prov_list)
+ journal = compose_all(composer_root ,prov_list ,language)
except (OSError ,RuntimeError) as error:
print(f"error: {error}" ,file=sys.stderr)
return 2
@@ -192,13 +218,7 @@ def orchestrate(path ,gate ,constraints ,flag):
if "compose_only" in flag:
return 0
- gate_path = Path(gate) if gate else DEFAULT_GATE
- if not os.access(gate_path ,os.X_OK):
- print(f"error: the gate {gate_path} is missing; in the developer role, build and promote it" ,file=sys.stderr)
- return 2
command = [str(gate_path) ,"admit"]
- if constraints:
- command += ["constraints" ,str(Path(constraints).resolve())]
if "check_only" in flag:
command.append("check_only")
return subprocess.run(command ,input=journal.encode()).returncode
@@ -234,7 +254,7 @@ def CLI(argv=None):
else:
print(f"Error: unknown or incomplete option '{word}'." ,file=sys.stderr)
return 2
- return orchestrate(path ,value.get("gate") ,value.get("constraints") ,flag)
+ return orchestrate(path ,value.get("gate") ,flag)
if __name__ == "__main__":
diff --git a/developer/authored/Man_In_Grey.copy/constraints_file.json b/developer/authored/Man_In_Grey.copy/constraints_file.json
deleted file mode 100644
index 134b3c9..0000000
--- a/developer/authored/Man_In_Grey.copy/constraints_file.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "gate_invoking_account": [],
- "file": []
-}
diff --git a/developer/authored/Man_In_Grey.copy/guard_emplace_bio.py b/developer/authored/Man_In_Grey.copy/guard_emplace_bio.py
new file mode 100644
index 0000000..963b90b
--- /dev/null
+++ b/developer/authored/Man_In_Grey.copy/guard_emplace_bio.py
@@ -0,0 +1,147 @@
+#!/usr/bin/env -S python3 -B
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+guard_emplace_bio - the guard of the emplace_bio instruction language.
+
+Started by the gate Man_In_Grey_emplace_bio_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 : ; the first two lines are name: and version:.
+
+When every check passes, it writes one line to standard output,
+
+ guard emplace_bio 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_emplace_bio
+ 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 have a page"
+ return False
+
+def is_allowed_text_size(byte_count):
+ "True when the display name and the text together can be byte_count bytes of UTF-8"
+ return byte_count <= 8192
+
+def is_allowed_picture_size(byte_count):
+ "True when the picture can be byte_count bytes"
+ return byte_count <= 1 << 20
+
+def page_directory():
+ "the absolute directory the pages are placed in, owned by the gate owner account"
+ return "/srv/http/bio"
+
+# constraints end
+# ------------------------------------------------------------------------------
+
+
+import sys ,os
+sys.dont_write_bytecode = True
+
+import pwd ,stat
+from pathlib import Path
+
+LANGUAGE = "emplace_bio"
+VERSION = "3"
+
+HERE_DPATH = Path(__file__).resolve().parent
+MODULE = ("Journal.py" ,"Guard.py" ,"Constraint.py" ,"EmplaceBioLanguage.py" ,"guard_emplace_bio.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 EmplaceBioLanguage 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_emplace_bio 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)
diff --git a/developer/authored/Man_In_Grey.copy/guard_file.py b/developer/authored/Man_In_Grey.copy/guard_file.py
index fd42913..641c7cf 100644
--- a/developer/authored/Man_In_Grey.copy/guard_file.py
+++ b/developer/authored/Man_In_Grey.copy/guard_file.py
@@ -4,9 +4,9 @@
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 uid, and with the journal on standard input. It checks the journal against the
-constraints, and says what it found on standard error, one line per
-finding, as : ; the first two lines are name: and version:.
+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 : ; the first two lines are name: and version:.
When every check passes, it writes one line to standard output,
@@ -15,50 +15,71 @@ When every check passes, it writes one line to standard output,
and exits 0. That line and that status together are the only verdict the
gate admits a journal on.
-Its constraints are constraints_file.json beside it. Through a gate that
-is not setuid, other constraints can be given with constraints ,
-since nothing then runs with more than the gate invoking account's own rights.
+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, its constraints
-file and their directory are owned by root or by the interpreter account,
-and writable by no other account. A module runs code when it is imported.
+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 [option ...]
- help | -h print this message (also printed when given no arguments)
- check check the journal on standard input
-Options of check:
- constraints constraints to use in place of constraints_file.json; refused
- when the gate changed the account or the interpreter account is root
+Usage: guard_file
+ 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 json ,pwd ,stat
+import pwd ,stat
from pathlib import Path
-GUARD_NAME = "file"
-VERSION = "1"
+LANGUAGE = "file"
+VERSION = "3"
HERE_DPATH = Path(__file__).resolve().parent
-CONSTRAINT_FILE = HERE_DPATH / "constraints_file.json"
-MODULE = ("Journal.py" ,"FileLanguage.py" ,"Guard.py" ,"guard_file.py")
-
+MODULE = ("Journal.py" ,"Guard.py" ,"Constraint.py" ,"FileLanguage.py" ,"guard_file.py")
-def account_changed():
- return os.geteuid() != os.getuid() or os.getegid() != os.getgid()
def trusted_owner_problem(path):
- "None when path is owned by root or the interpreter account and writable by no other account"
+ "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 interpreter account"
+ 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
@@ -66,11 +87,11 @@ def trusted_owner_problem(path):
def say(tag ,message):
print(f"{tag}: {message}" ,file=sys.stderr)
-if __name__ == "__main__" and sys.argv[1:2] == ["check"]:
- say("name" ,GUARD_NAME)
+if __name__ == "__main__" and sys.argv[1:] == ["check"]:
+ say("name" ,LANGUAGE)
say("version" ,VERSION)
-for each in [HERE_DPATH ,CONSTRAINT_FILE] + [HERE_DPATH / m for m in MODULE]:
+for each in [HERE_DPATH] + [HERE_DPATH / m for m in MODULE]:
problem = trusted_owner_problem(each)
if problem:
say("refused_to_start" ,problem)
@@ -78,8 +99,9 @@ for each in [HERE_DPATH ,CONSTRAINT_FILE] + [HERE_DPATH / m for m in MODULE]:
try:
import Journal
- import FileLanguage
import Guard
+ import Constraint
+ import FileLanguage as Language
except ImportError as error:
say("cannot_start" ,str(error))
sys.exit(2)
@@ -88,31 +110,12 @@ except ImportError as error:
def report(tag ,message):
say(tag ,Journal.clean(message))
-def load_constraint(given):
- if given is not None:
- if account_changed() or os.geteuid() == 0:
- raise RuntimeError("constraints are refused when the gate changed the account or the interpreter account is root")
- path = Path(given)
- problem = trusted_owner_problem(path)
- if problem:
- raise RuntimeError(problem)
- else:
- path = CONSTRAINT_FILE
- with open(path ,encoding="utf-8") as f:
- constraint = json.load(f)
- report("constraints" ,str(path))
- return constraint
-
-def check(given):
- try:
- constraint = load_constraint(given)
- except (OSError ,ValueError ,RuntimeError) as error:
- report("constraints_unusable" ,str(error))
- return 2
+def check():
+ report("constraints" ,str(Path(__file__).resolve()))
gate_invoking_account = pwd.getpwuid(os.getuid()).pw_name
- status = Guard.check(sys.stdin.buffer.read() ,FileLanguage ,constraint ,gate_invoking_account ,report)
+ status = Guard.check(sys.stdin.buffer.read() ,Language ,sys.modules[__name__] ,gate_invoking_account ,report)
if status == 0:
- sys.stdout.write(f"guard {GUARD_NAME} check passed\n")
+ sys.stdout.write(f"guard {LANGUAGE} check passed\n")
return status
@@ -126,19 +129,10 @@ def print_help():
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()
- command ,rest = argv[0] ,argv[1:]
- if command != "check":
- say("usage" ,f"unknown command {Journal.clean(repr(command))}; run guard_file with no arguments for its usage")
+ if argv != ["check"]:
+ say("usage" ,f"unknown command {Journal.clean(repr(' '.join(argv)))}; run guard_file with no arguments for its usage")
return 2
- given = None
- i = 0
- while i < len(rest):
- if rest[i] == "constraints" and i + 1 < len(rest):
- given = rest[i + 1]; i += 2
- else:
- say("usage" ,f"unknown or incomplete option {Journal.clean(repr(rest[i]))}")
- return 2
- return check(given)
+ return check()
if __name__ == "__main__":
diff --git a/developer/authored/Man_In_Grey.copy/install_gate b/developer/authored/Man_In_Grey.copy/install_gate
new file mode 100755
index 0000000..bf0d638
--- /dev/null
+++ b/developer/authored/Man_In_Grey.copy/install_gate
@@ -0,0 +1,260 @@
+#!/usr/bin/python3 -I
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+install_gate - install a Man_In_Grey gate, setuid to the account that is to own it.
+
+Run by root, by the administrator who installs an application. It copies
+Man_In_Grey/, the directory beside this program, and the one gate named
+from Man_In_Grey_gate/ into a directory of root's, side by side, as the
+gate's compiled paths expect. Every file and directory it installs is
+owned by root and writable by no other account, except the gate, which is
+given to the owner account and made setuid: mode 4755 when the owner is
+root, and 6755, setuid and setgid, with the owner's group, for any other
+account. It then prints the gate's flags.
+
+A guard's constraints are the predicates written at its top, between the
+lines '# constraints begin' and '# constraints end'. With constraints
+, the predicates in the file are put there, in the guard of the
+gate's language. Without it, the constraints of a guard already installed
+are kept, so installing again takes a new guard and keeps its constraints;
+a first installation gets the guard's as promoted, which refuse every
+account. The constraints of every other installed guard are kept too.
+
+It refuses a directory that is not absolute, that another account than
+root owns or can write, or that is on a file system mounted nosuid, where
+the kernel ignores the setuid and setgid bits.
+
+Usage: install_gate ...
+ help | -h print this message (also printed when given no arguments)
+ install owner into [constraints ]
+ is a gate's file name in Man_In_Grey_gate/, such as
+ Man_In_Grey_emplace_bio_gate; is a Python file holding
+ the guard's predicates
+
+Example, run from consumer/Man_In_Grey/:
+ sudo ./install_gate install Man_In_Grey_emplace_bio_gate owner http into /usr/local/lib/Man_In_Grey constraints bio_constraint.py
+
+Exit status: 0 when the gate was installed, 1 when the installation was
+refused, 2 when it was given wrong arguments.
+"""
+
+import sys ,os
+sys.dont_write_bytecode = True
+
+import grp ,pwd ,shutil ,stat ,subprocess
+from pathlib import Path
+
+HERE_DPATH = Path(__file__).resolve().parent # .../Man_In_Grey
+SOURCE_DPATH = HERE_DPATH.parent # holds Man_In_Grey/ and Man_In_Grey_gate/
+PROGRAM_DNAME = "Man_In_Grey"
+GATE_DNAME = "Man_In_Grey_gate"
+
+
+class Refused(Exception):
+ """The installation cannot go ahead."""
+
+
+def say(message):
+ print(f"install_gate: {message}")
+
+
+# --------------------------------------------------------------------------------
+# checks
+
+def root_chain_problem(path):
+ "None when path and every directory above it are owned by root and writable by no other account"
+ for each in [path] + list(path.parents):
+ st = os.lstat(each)
+ if stat.S_ISLNK(st.st_mode):
+ return f"{each} is a symbolic link"
+ if st.st_uid != 0:
+ return f"{each} is owned by uid {st.st_uid}, not root"
+ if st.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
+ return f"{each} is writable by group or other accounts"
+ return None
+
+def nosuid(path):
+ return bool(os.statvfs(path).f_flag & os.ST_NOSUID)
+
+
+# --------------------------------------------------------------------------------
+# copying
+
+def install_file(source ,target ,mode):
+ "copy source to target through a temporary file in target's directory, owned by root"
+ tmp = target.with_name(f".{target.name}.install_gate.{os.getpid()}")
+ shutil.copyfile(source ,tmp ,follow_symlinks=False)
+ os.chown(tmp ,0 ,0)
+ os.chmod(tmp ,mode)
+ os.rename(tmp ,target)
+
+def make_directory(path):
+ if not path.exists():
+ path.mkdir(mode=0o755)
+ st = os.lstat(path)
+ if not stat.S_ISDIR(st.st_mode):
+ raise Refused(f"{path} is not a directory")
+ os.chown(path ,0 ,0)
+ os.chmod(path ,0o755)
+
+BEGIN ,END = "# constraints begin" ,"# constraints end"
+
+def section_bound(text ,path):
+ "the line numbers of the two constraint marks in a guard's text; raises Refused"
+ line_list = text.splitlines()
+ if line_list.count(BEGIN) != 1 or line_list.count(END) != 1 or line_list.index(BEGIN) > line_list.index(END):
+ raise Refused(f"{path} does not hold the marks '{BEGIN}' and '{END}', once each and in that order")
+ return line_list ,line_list.index(BEGIN) ,line_list.index(END)
+
+def constraint_of(text ,path):
+ "the constraints between the marks"
+ line_list ,begin ,end = section_bound(text ,path)
+ return "\n".join(line_list[begin + 1:end]).strip("\n") + "\n"
+
+def with_constraint(text ,path ,constraint):
+ "the guard's text with constraint between the marks"
+ line_list ,begin ,end = section_bound(text ,path)
+ return "\n".join(line_list[:begin + 1] + ["" ,constraint.strip("\n") ,""] + line_list[end:]) + "\n"
+
+def install_text(text ,target ,mode):
+ "write text to target through a temporary file in target's directory, owned by root"
+ tmp = target.with_name(f".{target.name}.install_gate.{os.getpid()}")
+ tmp.write_text(text ,encoding="utf-8")
+ os.chown(tmp ,0 ,0)
+ os.chmod(tmp ,mode)
+ os.rename(tmp ,target)
+
+def gate_flag(gate_fpath):
+ result = subprocess.run([str(gate_fpath) ,"flags"] ,capture_output=True ,text=True ,env={"PATH": "/usr/bin:/bin"})
+ if result.returncode != 0:
+ raise Refused(f"{gate_fpath} did not report its flags")
+ return result.stdout ,dict(line.split("=" ,1) for line in result.stdout.splitlines() if "=" in line)
+
+
+# --------------------------------------------------------------------------------
+# installing
+
+def install(gate ,owner ,directory ,constraints=None):
+ """
+ Install the gate named gate, setuid to the account owner, into directory.
+ Raises Refused.
+ """
+ if os.geteuid() != 0:
+ raise Refused("install_gate is run by root, as: sudo install_gate ...")
+ if "/" in gate or gate in ("" ,"." ,".."):
+ raise Refused(f"{gate!r} is to be the file name of a gate in {SOURCE_DPATH / GATE_DNAME}")
+ source_gate = SOURCE_DPATH / GATE_DNAME / gate
+ if not source_gate.is_file() or source_gate.is_symlink():
+ raise Refused(f"there is no gate {source_gate}")
+ try:
+ account = pwd.getpwnam(owner)
+ except KeyError:
+ raise Refused(f"there is no account {owner!r}")
+ given_constraints = None
+ if constraints is not None:
+ given_constraints = Path(constraints).resolve()
+ if not given_constraints.is_file():
+ raise Refused(f"there is no constraints file {constraints}")
+ given_text = given_constraints.read_text(encoding="utf-8")
+ if BEGIN in given_text.splitlines() or END in given_text.splitlines():
+ raise Refused(f"{constraints} is to hold the predicates only, without the marks")
+ try:
+ compile(given_text ,str(given_constraints) ,"exec")
+ except SyntaxError as error:
+ raise Refused(f"{constraints} is not Python: {error}")
+
+ target = Path(directory)
+ if not target.is_absolute():
+ raise Refused(f"{directory} is not an absolute path")
+ if not target.parent.is_dir():
+ raise Refused(f"{target.parent} is not a directory")
+ problem = root_chain_problem(target.parent)
+ if problem:
+ raise Refused(problem)
+ make_directory(target)
+ if nosuid(target):
+ raise Refused(f"{target} is on a file system mounted nosuid, where the kernel ignores the setuid bit")
+
+ program_dpath ,gate_dpath = target / PROGRAM_DNAME ,target / GATE_DNAME
+ make_directory(program_dpath)
+ make_directory(gate_dpath)
+
+ # the constraints of the guards installed already, to keep
+ kept = {}
+ for installed in sorted(program_dpath.glob("guard_*.py")):
+ if installed.is_file() and not installed.is_symlink():
+ try:
+ kept[installed.name] = constraint_of(installed.read_text(encoding="utf-8") ,installed)
+ except Refused as problem:
+ say(f"{problem}; it holds no constraints to keep")
+
+ # Man_In_Grey/: every regular file, a guard with its kept constraints
+ for source in sorted((SOURCE_DPATH / PROGRAM_DNAME).iterdir()):
+ st = os.lstat(source)
+ if not stat.S_ISREG(st.st_mode):
+ continue
+ destination = program_dpath / source.name
+ mode = 0o755 if st.st_mode & stat.S_IXUSR else 0o644
+ if source.name in kept:
+ install_text(with_constraint(source.read_text(encoding="utf-8") ,source ,kept[source.name]) ,destination ,mode)
+ say(f"kept the constraints of {destination}")
+ else:
+ install_file(source ,destination ,mode)
+ say(f"installed {program_dpath}/")
+
+ # the gate, owned by root until it is complete
+ installed_gate = gate_dpath / gate
+ install_file(source_gate ,installed_gate ,0o755)
+ _ ,flag = gate_flag(installed_gate)
+ language = flag.get("language" ,"")
+ guard_path = Path(flag.get("guard.path" ,""))
+ if not language or guard_path.parent != program_dpath:
+ raise Refused(f"{installed_gate} does not find its guard in {program_dpath}")
+
+ if given_constraints is not None:
+ install_text(with_constraint(guard_path.read_text(encoding="utf-8") ,guard_path ,given_text) ,guard_path ,0o644)
+ say(f"put the constraints of {given_constraints} in {guard_path}")
+
+ # chown clears the setuid and setgid bits, so the mode is set after it
+ os.chown(installed_gate ,account.pw_uid ,account.pw_gid)
+ mode = 0o4755 if account.pw_uid == 0 else 0o6755
+ os.chmod(installed_gate ,mode)
+ group = grp.getgrgid(account.pw_gid).gr_name
+ say(f"installed {installed_gate}, owner {owner}:{group}, mode {mode:o}")
+
+ problem = root_chain_problem(program_dpath)
+ if problem:
+ raise Refused(problem)
+ text ,_ = gate_flag(installed_gate)
+ say("its flags, as seen by root:")
+ sys.stdout.write(text)
+ say(f"run it as: {program_dpath / 'Man_In_Grey'} orchestrate gate {installed_gate}")
+ return 0
+
+
+# --------------------------------------------------------------------------------
+# 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()
+ command ,rest = argv[0] ,argv[1:]
+ if command != "install":
+ print(f"Error: unknown command '{command}'." ,file=sys.stderr)
+ return 2
+ if len(rest) not in (5 ,7) or rest[1] != "owner" or rest[3] != "into" or (len(rest) == 7 and rest[5] != "constraints"):
+ print("Error: install owner into [constraints ]" ,file=sys.stderr)
+ return 2
+ try:
+ return install(rest[0] ,rest[2] ,rest[4] ,rest[6] if len(rest) == 7 else None)
+ except Refused as error:
+ print(f"install_gate: refused: {error}" ,file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(CLI())
diff --git a/developer/authored/Man_In_Grey.copy/interpreter_emplace_bio.py b/developer/authored/Man_In_Grey.copy/interpreter_emplace_bio.py
new file mode 100644
index 0000000..cc605c8
--- /dev/null
+++ b/developer/authored/Man_In_Grey.copy/interpreter_emplace_bio.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env -S python3 -B
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+interpreter_emplace_bio - the interpreter of the emplace_bio instruction language.
+
+Started by the gate Man_In_Grey_emplace_bio_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 : , 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_emplace_bio.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_emplace_bio
+ 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" ,"EmplaceBioLanguage.py" ,"guard_emplace_bio.py" ,"interpreter_emplace_bio.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 EmplaceBioLanguage as Language
+ import guard_emplace_bio 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_emplace_bio 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)
diff --git a/developer/authored/Man_In_Grey.copy/interpreter_file.py b/developer/authored/Man_In_Grey.copy/interpreter_file.py
index bf5ea95..078ac8c 100644
--- a/developer/authored/Man_In_Grey.copy/interpreter_file.py
+++ b/developer/authored/Man_In_Grey.copy/interpreter_file.py
@@ -4,18 +4,20 @@
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 uid, and with the journal on standard input, once the guard has
-passed the journal.
-It executes the journal's instructions and writes one line to standard output
-for each thing done, as : .
+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 : , 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.
+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 interpreter account, and writable by no other
-account.
+are owned by root or by the effective uid's account, and writable by no
+other account.
Usage: interpreter_file
help | -h print this message (also printed when given no arguments)
@@ -32,17 +34,17 @@ import stat
from pathlib import Path
HERE_DPATH = Path(__file__).resolve().parent
-MODULE = ("Journal.py" ,"FileLanguage.py" ,"Interpreter.py" ,"interpreter_file.py")
+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 interpreter account and writable by no other account"
+ "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 interpreter account"
+ 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
@@ -58,8 +60,9 @@ for each in [HERE_DPATH] + [HERE_DPATH / m for m in MODULE]:
try:
import Journal
- import FileLanguage
import Interpreter
+ import FileLanguage as Language
+ import guard_file as Guard_program
except ImportError as error:
say("cannot_start" ,str(error))
sys.exit(2)
@@ -69,7 +72,7 @@ def log(tag ,message):
print(f"{tag}: {Journal.clean(message)}" ,flush=True)
def execute():
- return Interpreter.interpret(sys.stdin.buffer.read() ,FileLanguage ,log)
+ return Interpreter.interpret(sys.stdin.buffer.read() ,Language ,log ,Guard_program)
# --------------------------------------------------------------------------------
diff --git a/developer/authored/Man_In_Grey_gate.make/Gate.lib.c b/developer/authored/Man_In_Grey_gate.make/Gate.lib.c
index 6d30c38..e762e2f 100644
--- a/developer/authored/Man_In_Grey_gate.make/Gate.lib.c
+++ b/developer/authored/Man_In_Grey_gate.make/Gate.lib.c
@@ -3,8 +3,8 @@
A gate is a program of its own, made from this module by a three-line
*.CLI.c whose main calls Man_In_Grey_gate·Gate·run with three things fixed
- at compile time: the guard's name, the guard's path and the interpreter's
- path, the two paths relative to the gate itself. The gate invoking account can
+ at compile time: the name of the instruction language, the guard's path and
+ the interpreter's path, the two paths relative to the gate itself. The gate invoking account can
name neither program; who invokes a gate cannot choose what it runs.
Setuid and owned by account B, a gate runs with the effective uid of
@@ -19,19 +19,23 @@
2, and raises each resource limit that could stop the guard or the
interpreter partway, refusing when one stays too low;
1. refuses unless the guard and the interpreter, and every directory
- above them, are owned by root or by the interpreter account and
+ above them, are owned by root or by the effective uid's account and
writable by no other account;
2. clears the environment, sets the file mode mask to 077, marks the
- process not dumpable, changes directory to /, and records the
- gate invoking account in MIG_GATE_INVOKING_UID, MIG_GATE_INVOKING_GID and
- MIG_GATE_INVOKING_NAME;
+ process not dumpable, changes directory to /, and records its real
+ ids in MIG_REAL_UID, MIG_REAL_GID and MIG_REAL_USER_NAME;
3. starts the guard with the journal on its standard input, and admits
the journal only when the guard exits 0 having written exactly one
- line to standard output: guard check passed;
+ line to standard output: guard check passed;
4. starts the interpreter with the same journal.
- Every line the guard and the interpreter write is copied to the person and
- to the system log. A line that is not UTF-8, or holds a control character,
+ The constraints are code installed with the guard, so nothing about them is
+ given to the gate.
+
+ The gate logs each journal's course itself, whatever the guard and the
+ interpreter write: received, then approved or refused, then interpreting,
+ then interpreted or failed. Every line the guard and the interpreter
+ write is copied to the person and to the system log as well. A line that is not UTF-8, or holds a control character,
or is too long, or one line too many, breaks the gate's rules: the gate
stops the program and refuses.
@@ -44,7 +48,7 @@
#define Man_In_Grey_gate·Gate·ONCE
int Man_In_Grey_gate·Gate·run(
- const char *guard_name
+ const char *language
,const char *guard_relative
,const char *interpreter_relative
,int argc
@@ -56,6 +60,7 @@ int Man_In_Grey_gate·Gate·run(
#include
#include
#include
+ #include
#include
#include
#include
@@ -79,7 +84,7 @@ int Man_In_Grey_gate·Gate·run(
#define OUTPUT_LINE_COUNT_MAX 2000
#define VERDICT_MAX 512
- Local const char *Guard_name = "";
+ Local const char *Language = "";
Local bool Logging = false;
// ------------------------------------------------------------------------------
@@ -91,7 +96,7 @@ int Man_In_Grey_gate·Gate·run(
vsnprintf(message ,sizeof message ,format ,ap);
va_end(ap);
fprintf(stderr ,"gate: %s: %s\n" ,tag ,message);
- if( Logging ) syslog(LOG_NOTICE ,"%s gate: %s: %s" ,Guard_name ,tag ,message);
+ if( Logging ) syslog(LOG_NOTICE ,"%s gate: %s: %s" ,Language ,tag ,message);
}
Local void die(const char *format ,...){
@@ -106,10 +111,6 @@ int Man_In_Grey_gate·Gate·run(
// ------------------------------------------------------------------------------
// the ownership check
- Local bool account_changed(void){
- return geteuid() != getuid() || getegid() != getgid();
- }
-
/* path and every directory above it: owned by root or by the interpreter
account, the effective uid, and writable by no other account */
Local void check_trusted_owner(const char *path){
@@ -124,7 +125,7 @@ int Man_In_Grey_gate·Gate·run(
memcpy(prefix ,path ,i); prefix[i] = '\0';
if( lstat(prefix ,&st) != 0 ) die("cannot examine %s: %s" ,prefix ,strerror(errno));
if( st.st_uid != 0 && st.st_uid != interpreter_uid )
- die("%s is owned by uid %ld, neither root nor the interpreter account" ,prefix ,(long)st.st_uid);
+ die("%s is owned by uid %ld, neither root nor the effective uid's account" ,prefix ,(long)st.st_uid);
if( st.st_mode & (S_IWGRP | S_IWOTH) )
die("%s is writable by group or other accounts" ,prefix);
}
@@ -225,7 +226,7 @@ int Man_In_Grey_gate·Gate·run(
return realpath(candidate ,resolved) != NULL;
}
- // a guard name: not empty, no space, no control character
+ // a language name: not empty, no space, no control character
Local bool name_is_valid(const char *name){
if( !*name ) return false;
for(const unsigned char *p = (const unsigned char *)name; *p; ++p)
@@ -295,7 +296,7 @@ int Man_In_Grey_gate·Gate·run(
stream->line[stream->length] = '\0';
fprintf(stream->echo ,"%s: %s\n" ,child->source ,stream->line);
fflush(stream->echo);
- syslog(LOG_NOTICE ,"%s %s: %s" ,Guard_name ,child->source ,stream->line);
+ syslog(LOG_NOTICE ,"%s %s: %s" ,Language ,child->source ,stream->line);
stream->length = 0;
}
@@ -395,26 +396,38 @@ int Man_In_Grey_gate·Gate·run(
fprintf(file ,
"Usage: %s [option ...]\n"
" help | -h print this message (also printed when given no arguments)\n"
- " flags print the gate invoking account, the interpreter account, the guard\n"
- " and the interpreter, and whether the gate changes the account\n"
+ " flags print the real and effective ids, whether they differ, the\n"
+ " instruction language, and the paths of the guard and interpreter\n"
" admit [option ...] read a journal on standard input, run the guard, and when the\n"
" guard passes the journal, run the interpreter\n"
- "Options of admit:\n"
- " constraints given to the guard\n"
+ "Option of admit:\n"
" check_only run the guard and not the interpreter\n"
,program
);
}
+ Local const char *user_name(uid_t uid){
+ struct passwd *pw = getpwuid(uid);
+ return pw ? pw->pw_name : "unknown";
+ }
+
+ Local const char *group_name(gid_t gid){
+ struct group *gr = getgrgid(gid);
+ return gr ? gr->gr_name : "unknown";
+ }
+
Local void print_flags(const char *guard_relative ,const char *interpreter_relative){
- struct passwd *pw = getpwuid(getuid());
- printf("gate_invoking.uid=%ld\n" ,(long)getuid());
- printf("gate_invoking.name=%s\n" ,pw ? pw->pw_name : "unknown");
- pw = getpwuid(geteuid());
- printf("interpreter.uid=%ld\n" ,(long)geteuid());
- printf("interpreter.name=%s\n" ,pw ? pw->pw_name : "unknown");
- printf("account_changed=%d\n" ,account_changed() ? 1 : 0);
- printf("guard.name=%s\n" ,Guard_name);
+ printf("real.uid=%ld\n" ,(long)getuid());
+ printf("real.user_name=%s\n" ,user_name(getuid()));
+ printf("real.gid=%ld\n" ,(long)getgid());
+ printf("real.group_name=%s\n" ,group_name(getgid()));
+ printf("effective.uid=%ld\n" ,(long)geteuid());
+ printf("effective.user_name=%s\n" ,user_name(geteuid()));
+ printf("effective.gid=%ld\n" ,(long)getegid());
+ printf("effective.group_name=%s\n" ,group_name(getegid()));
+ printf("uid_differ=%d\n" ,getuid() != geteuid() ? 1 : 0);
+ printf("gid_differ=%d\n" ,getgid() != getegid() ? 1 : 0);
+ printf("language=%s\n" ,Language);
char resolved[PATH_MAX];
printf("guard.path=%s\n" ,resolve(guard_relative ,resolved) ? resolved : "(missing)");
printf("interpreter.path=%s\n" ,resolve(interpreter_relative ,resolved) ? resolved : "(missing)");
@@ -445,13 +458,13 @@ int Man_In_Grey_gate·Gate·run(
}
int Man_In_Grey_gate·Gate·run(
- const char *guard_name
+ const char *language
,const char *guard_relative
,const char *interpreter_relative
,int argc
,char **argv
){
- Guard_name = guard_name;
+ Language = language;
const char *program = argc > 0 ? argv[0] : "gate";
if( argc < 2 || !strcmp(argv[1] ,"help") || !strcmp(argv[1] ,"-h") || !strcmp(argv[1] ,"--help") ){
print_usage(stdout ,program);
@@ -466,10 +479,8 @@ int Man_In_Grey_gate·Gate·run(
print_usage(stderr ,program);
return 2;
}
- const char *constraints = NULL;
bool check_only = false;
for(int i = 2; i < argc; ++i){
- if( !strcmp(argv[i] ,"constraints") && i + 1 < argc ){ constraints = argv[++i]; continue; }
if( !strcmp(argv[i] ,"check_only") ){ check_only = true; continue; }
fprintf(stderr ,"gate: usage: unknown or incomplete option '%s'\n" ,argv[i]);
return 2;
@@ -478,26 +489,25 @@ int Man_In_Grey_gate·Gate·run(
settle_descriptors();
settle_limits();
- if( !name_is_valid(guard_name) ) die("the guard name compiled into this gate is empty or holds a space or control character");
+ if( !name_is_valid(language) ) die("the language name compiled into this gate is empty or holds a space or control character");
char guard_path[PATH_MAX] ,interpreter_path[PATH_MAX];
if( !resolve(guard_relative ,guard_path) ) die("the guard is missing: %s" ,guard_relative);
if( !resolve(interpreter_relative ,interpreter_path) ) die("the interpreter is missing: %s" ,interpreter_relative);
check_trusted_owner(guard_path);
check_trusted_owner(interpreter_path);
- uid_t gate_invoking_uid = getuid();
- gid_t gate_invoking_gid = getgid();
- struct passwd *pw = getpwuid(gate_invoking_uid);
- char gate_invoking_name[256];
- snprintf(gate_invoking_name ,sizeof gate_invoking_name ,"%s" ,pw ? pw->pw_name : "unknown");
+ uid_t real_uid = getuid();
+ gid_t real_gid = getgid();
+ char real_user_name[256];
+ snprintf(real_user_name ,sizeof real_user_name ,"%s" ,user_name(real_uid));
clearenv();
setenv("PATH" ,"/usr/sbin:/usr/bin:/sbin:/bin" ,1);
setenv("LANG" ,"C.UTF-8" ,1);
char number[32];
- snprintf(number ,sizeof number ,"%ld" ,(long)gate_invoking_uid); setenv("MIG_GATE_INVOKING_UID" ,number ,1);
- snprintf(number ,sizeof number ,"%ld" ,(long)gate_invoking_gid); setenv("MIG_GATE_INVOKING_GID" ,number ,1);
- setenv("MIG_GATE_INVOKING_NAME" ,gate_invoking_name ,1);
+ snprintf(number ,sizeof number ,"%ld" ,(long)real_uid); setenv("MIG_REAL_UID" ,number ,1);
+ snprintf(number ,sizeof number ,"%ld" ,(long)real_gid); setenv("MIG_REAL_GID" ,number ,1);
+ setenv("MIG_REAL_USER_NAME" ,real_user_name ,1);
umask(077);
prctl(PR_SET_DUMPABLE ,0 ,0 ,0 ,0);
if( chdir("/") != 0 ) die("cannot change directory to /");
@@ -508,13 +518,12 @@ int Man_In_Grey_gate·Gate·run(
size_t journal_length;
unsigned char *journal = read_journal(&journal_length);
- say("asked" ,"gate invoking account %s (uid %ld), journal of %zu bytes%s"
- ,gate_invoking_name ,(long)gate_invoking_uid ,journal_length ,check_only ? ", check only" : "");
+ say("received" ,"real uid %ld (%s), effective uid %ld (%s), journal of %zu bytes%s"
+ ,(long)real_uid ,real_user_name ,(long)geteuid() ,user_name(geteuid()) ,journal_length ,check_only ? ", check only" : "");
// the guard
char *guard_argument[] = {
- (char *)PYTHON ,(char *)"-E" ,(char *)"-s" ,(char *)"-B" ,guard_path ,(char *)"check"
- ,constraints ? (char *)"constraints" : NULL ,(char *)constraints ,NULL
+ (char *)PYTHON ,(char *)"-E" ,(char *)"-s" ,(char *)"-B" ,guard_path ,(char *)"check" ,NULL
};
Child guard = {.source = "guard" ,.stdout_is_verdict = true};
int status = run_child(&guard ,guard_argument ,journal ,journal_length);
@@ -523,7 +532,7 @@ int Man_In_Grey_gate·Gate·run(
return 2;
}
char expected[VERDICT_MAX];
- snprintf(expected ,sizeof expected ,"guard %s check passed\n" ,guard_name);
+ snprintf(expected ,sizeof expected ,"guard %s check passed\n" ,language);
if( !WIFEXITED(status) ){
say("refused" ,"the guard was stopped by signal %d" ,WIFSIGNALED(status) ? WTERMSIG(status) : 0);
return 2;
@@ -537,12 +546,12 @@ int Man_In_Grey_gate·Gate·run(
return 2;
}
if( strcmp(guard.verdict ,expected) ){
- say("refused" ,"the guard exited 0 without writing the one line 'guard %s check passed'" ,guard_name);
+ say("refused" ,"the guard exited 0 without writing the one line 'guard %s check passed'" ,language);
return 2;
}
- say("admitted" ,"guard %s check passed" ,guard_name);
+ say("approved" ,"guard %s check passed" ,language);
if( check_only ){
- say("not_executed" ,"check_only was asked");
+ say("not_interpreted" ,"check_only was asked");
return 0;
}
@@ -551,6 +560,7 @@ int Man_In_Grey_gate·Gate·run(
(char *)PYTHON ,(char *)"-E" ,(char *)"-s" ,(char *)"-B" ,interpreter_path ,(char *)"execute" ,NULL
};
Child interpreter = {.source = "interpreter" ,.stdout_is_verdict = false};
+ say("interpreting" ,"%s" ,interpreter_path);
status = run_child(&interpreter ,interpreter_argument ,journal ,journal_length);
if( interpreter.broken ){
say("failed" ,"the interpreter broke the gate's rules: %s; what it had done stays done" ,interpreter.broken);
@@ -561,8 +571,8 @@ int Man_In_Grey_gate·Gate·run(
return 2;
}
switch( WEXITSTATUS(status) ){
- case 0: say("executed" ,"the journal was executed"); return 0;
- case 1: say("failed" ,"execution failed"); return 1;
+ case 0: say("interpreted" ,"the interpreter finished the journal"); return 0;
+ case 1: say("failed" ,"the interpreter stopped partway; what it had done stays done"); return 1;
default: say("failed" ,"the interpreter could not run (exit status %d)" ,WEXITSTATUS(status)); return 2;
}
}
diff --git a/developer/authored/Man_In_Grey_gate.make/Man_In_Grey_emplace_bio_gate.CLI.c b/developer/authored/Man_In_Grey_gate.make/Man_In_Grey_emplace_bio_gate.CLI.c
new file mode 100644
index 0000000..cdf0fcd
--- /dev/null
+++ b/developer/authored/Man_In_Grey_gate.make/Man_In_Grey_emplace_bio_gate.CLI.c
@@ -0,0 +1,11 @@
+/*
+ Man_In_Grey_emplace_bio_gate - the gate of the emplace_bio 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("emplace_bio" ,"../Man_In_Grey/guard_emplace_bio.py"
+ ,"../Man_In_Grey/interpreter_emplace_bio.py" ,argc ,argv);
+}
diff --git a/developer/authored/Man_In_Grey_gate.make/Man_In_Grey_file_gate.CLI.c b/developer/authored/Man_In_Grey_gate.make/Man_In_Grey_file_gate.CLI.c
index 282f940..1659b7c 100644
--- a/developer/authored/Man_In_Grey_gate.make/Man_In_Grey_file_gate.CLI.c
+++ b/developer/authored/Man_In_Grey_gate.make/Man_In_Grey_file_gate.CLI.c
@@ -1,6 +1,6 @@
/*
Man_In_Grey_file_gate - the gate of the file instruction language: the general
- gate with its guard's name and the guard's and interpreter's paths fixed.
+ gate with the language name and the guard's and interpreter's paths fixed.
*/
#include "Gate.lib.c"
diff --git a/document/Man_In_Grey.html b/document/Man_In_Grey.html
index 7920375..accd582 100644
--- a/document/Man_In_Grey.html
+++ b/document/Man_In_Grey.html
@@ -84,6 +84,9 @@
+
+ A function written at the top of a guard, between the lines # constraints begin and # constraints end, that answers whether a journal's request is allowed, such as is_allowed_account(user_name). See .
+
The interpreter is invoked by the gate after, and only if, the guard has approved the journal. The interperter walks the journal while executing the instructions in order.
@@ -191,7 +194,7 @@
- Th admit command takes the journal on standard input, with the options constraints <file> and check_only. When the gate is asked to admit a journal, it:
+ Th admit command takes the journal on standard input, with the option check_only. When the gate is asked to admit a journal, it:
@@ -206,7 +209,7 @@
starts the interpreter the same way, with the same journal.
- The guard and the interpreter, before they import anything, each check that every module they load, and the guard its constraints file, are owned by root or by account B, and writable by no other account. A module runs code when it is imported, so the check comes first.
+ The guard and the interpreter, before they import anything, each check that every module they load, the guard program among them, is owned by root or by account B, and writable by no other account. A module runs code when it is imported, so the check comes first.
These checks are made on every run, whether or not the gate is setuid, and whoever account A is. A check that can be got around by other means still catches mistakes, such as a group-writable directory or an interpreter run from the wrong tree. When accounts A and B are the same, account B is the person's own account, so his own files pass.
@@ -215,7 +218,7 @@
The constraints, not the gate, decide which accounts can give a journal. The gate decides only whether the guard and the interpreter it would start can be trusted, and whether the guard approved the journal.
- A gate that is not setuid keeps the uids of the program that invoked it, so accounts A and B are normally the same, and the guard still applies. When they are the same, the guard and the interpreter accept constraints given with constraints <file>, which the gate passes to both, checked as their own files are, since nothing runs with more than account A's own rights. When they differ, or account B is root, both refuse them and read only the constraints file installed beside them. A gate that is not setuid is the convenient way to use Man_In_Grey on files the person owns, and the way to prototype an instruction language before making a gate setuid for it.
+ A gate that is not setuid keeps the uids of the program that invoked it, so accounts A and B are normally the same, and the guard still applies. A gate that is not setuid is the convenient way to use Man_In_Grey on files the person owns, and the way to prototype an instruction language before making a gate setuid for it.
One gate per instruction language
@@ -237,7 +240,7 @@
Output rule
- Every line the guard and the interpreter write is copied to the person, marked guard: or interpreter:, and to the system log under the name Man_In_Grey_gate, beside the gate's own lines, marked gate:, saying who asked and what the gate decided. 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, file names in Chinese included. The guard writes any control character it quotes as \u{XXXX}, 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 guard: or interpreter:, and to the system log under the name Man_In_Grey_gate, beside the gate's own lines, marked gate:. The gate logs each journal's course itself, whatever the guard and the interpreter write: received, with account A, account B and the journal's size; then approved or refused; then interpreting; then interpreted or failed. 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 \u{XXXX}, and the wellformed check refuses a directory, file name or owner that holds one.
@@ -266,11 +269,34 @@
The kernel tells the gate which account invoked it, as the real uid, and the gate passes it on, so the guard knows it also. The kernel does not say which program invoked the gate. As the guard invocation is compiled into the gate, the guard can know the provenance of the journal from the point it was given to the gate, but not before that.
- 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, and the interpreter makes it again before executing. 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 accounts its constraints list, or from the members of a group they name. The file language's guard checks account A by name; a check by group is not yet made.
+ 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.
The other checks a guard makes belong to its instruction language. The file language's four checks are in .
+
+ 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 constraint predicate, named in the manner of is_allowed_account(user_name) or is_allowed_open(directory ,name ,mode ,owner ,permission), and the guard refuses a journal when one does not answer True. 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 # constraints begin and # constraints end, so that install_gate 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.
+
+
+ Some constraints recur from one guard to the next, and the library module Constraint.py holds them, for a guard's predicates to call:
+
+
+
+ The account is in the group by the group database: listed as a member in /etc/group, or having it as its primary group.
+
+
+ The gate's real gid or supplementary groups include the group. These are what the invoking program held, passed on by the kernel, so an account that joined the group after its session began does not have it yet, though is_group_member already says yes.
+
+
+ The path is the directory or lies within it, both absolute and in normal form.
+
+
+ The permission sets no bit the limit does not: 0o600 is within 0o644, and 0o4755, setuid, is within neither 0o644 nor 0o755.
+
+
+
+ The guard checks and imports its modules, Constraint.py among them, only after its predicates are defined, and calls the predicates only after that, so a predicate can call Constraint.is_group_member by name, though the import comes later in the file. Code at the top level of the constraints runs before the ownership check, so the constraints hold only definitions, and imports of the standard library at most. The interpreter imports the guard program, for what execution needs from its constraints.
+
Verdict
@@ -285,13 +311,13 @@
Interpreter
- 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 reads them, from the same file as the guard, only for what execution needs, as the example language's interpreter takes from them 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. 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 page_directory() 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 interpreter writes one line to standard output for each thing it does, as <tag>: <message>, and the gate copies each line to the person and to the system log by the output rule. Its exit status is 0 when the journal was executed, 1 when execution failed, and 2 when the journal could not be read or is not well formed.
+ The interpreter writes to standard output, as <tag>: <message>, 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 writes 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.
- 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 .
+ 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 . Playing a journal as a transaction is a possible future feature; see .
@@ -338,7 +364,7 @@
There is no close. 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 replace, a hard link that refuses an existing name for create, and for replace_keep 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.
- Execution stops at the first error, and every temporary file not yet put in place is removed. An unlink earlier in the journal has already happened by then. The interpreter writes a line for each thing it does, such as replaced: /etc/unbound/unbound-US.conf (kept as unbound-US.conf.20261105T083807Z).
+ Execution stops at the first error, and every temporary file not yet put in place is removed. An unlink 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 replaced: /etc/unbound/unbound-US.conf (kept as unbound-US.conf.20261105T083807Z), and none for an open or a write.
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 <step>_broken and each step that passes as check_passed:
@@ -348,7 +374,7 @@
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 write naming the symbol of an open.
- Account A is listed in gate_invoking_account; every file opened or unlinked is listed, in that mode, with that owner and permission.
+ is_allowed_account allows account A; is_allowed_open allows each file opened, in its mode, with its owner and permission; is_allowed_unlink allows each file removed.
Every owner names an account.
@@ -358,21 +384,20 @@
- The constraints of the file language are a JSON file:
+ The constraints at the top of the file language's guard, guard_file.py, allowing Thomas to keep one unbound configuration file and to remove another:
- mode lists the open modes allowed for the file, and unlink when it can be removed. gate_invoking_account lists the accounts allowed to be account A, giving journals to the gate. The constraints file installed with the interpreter lists no account and no file, so it refuses everything until it is replaced by the constraints the application developer wrote.
+ The permission is given to is_allowed_open as an integer, so 0o644 and "0644" in a composer both arrive as 0o644.
@@ -418,17 +443,15 @@
consumer/Man_In_Grey/Man_In_Grey.py the orchestrator
consumer/Man_In_Grey/install_gate installs a gate setuid, run by root
consumer/Man_In_Grey/Journal.py symbols, instructions, the journal, CBOR
- consumer/Man_In_Grey/Constraint.py loading the constraints
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
+ 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/constraints_file.json its constraints, refusing everything
consumer/Man_In_Grey/EmplaceBioLanguage.py the example language
consumer/Man_In_Grey/guard_emplace_bio.py its guard
consumer/Man_In_Grey/interpreter_emplace_bio.py its interpreter
- consumer/Man_In_Grey/constraints_emplace_bio.json its constraints, refusing everything
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/libMan_In_Grey.a the general gate, for other gates
@@ -489,9 +512,6 @@
The gate to invoke, which also sets the instruction language the composers write in. Default: ../Man_In_Grey_gate/Man_In_Grey_file_gate from Man_In_Grey.py.
-
- Constraints for a run in which accounts A and B are the same, given to the guard and the interpreter; refused when they differ or account B is root.
-
Print the journal after composing.
@@ -519,21 +539,21 @@
It copies Man_In_Grey/ and the one gate named from Man_In_Grey_gate/ into the directory, side by side, as the gate's compiled paths expect. Every file and directory it installs is owned by root and writable by no other account, except the gate, which it gives to account B, the owner account named, and then makes setuid. The order matters, since changing a file's owner clears its setuid bit. For root as account B the gate's mode is 4755, the mode 755 with the setuid bit, 4000, added. For any other account B it is 6755, with the setgid bit, 2000, too, and account B's group as the gate's group. Only root can give a file away, so an interpreter whose effective uid is that of an account B other than root makes files account B owns, with the effective gid's group; setgid makes that account B's group.
- The constraints given with constraints <file> are installed as constraints_<language>.json. Without the option, an installed constraints file is kept, and a first installation gets the one promoted with Man_In_Grey, which refuses everything. install_gate refuses a directory that is not absolute, that an account other than root owns or can write, or that is on a file system mounted nosuid, where the kernel ignores the setuid and setgid bits, as it does on removable drives and some separately mounted /home directories. It ends by printing the installed gate's flags, as root sees them: real uid root's, effective uid account B's. Promotion replaces files and sets neither the owner nor the setuid bit, so the installation is redone after each promotion that changes the gate, the guard or the interpreter. The installation is a candidate for Man_In_Grey itself, once there is a language for installing programs.
+ With constraints <file>, a Python file of predicates, install_gate puts them between the constraint marks of the gate's guard. Without it, the constraints of a guard already installed are kept, so installing again brings a new guard and keeps the administrator's constraints; a first installation gets the guard's as promoted, which refuse every account. The constraints of every other installed guard are kept too. install_gate refuses a directory that is not absolute, that an account other than root owns or can write, or that is on a file system mounted nosuid, where the kernel ignores the setuid and setgid bits, as it does on removable drives and some separately mounted /home directories. It ends by printing the installed gate's flags, as root sees them: real uid root's, effective uid account B's. Promotion replaces files and sets neither the owner nor the setuid bit, so the installation is redone after each promotion that changes the gate, the guard or the interpreter. The installation is a candidate for Man_In_Grey itself, once there is a language for installing programs.
Testing
- The tester has two tests. Each runs the promoted Man_In_Grey through a gate that is not setuid, so accounts A and B are the same, with constraints it writes into its scratchpad directory. The test tester/authored/write_file runs the file language's gate, and checks twenty-five things. Two runs of composers using every mode and unlink are compared with expected/run_1 and expected/run_2, with backup times replaced by the letters YYYYMMDDTHHMMSSZ. A file opened in create 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 check_only 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 / down to the promoted interpreter is to be writable by no account but its owner. In the tester role, from its directory tester/:
+ 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 / down to the scratchpad directory is to be writable by no account but its owner too. The test tester/authored/write_file runs the file language's gate, and checks twenty-five things. Two runs of composers using every mode and unlink are compared with expected/run_1 and expected/run_2, with backup times replaced by the letters YYYYMMDDTHHMMSSZ. A file opened in create 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 check_only 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 / down to the promoted interpreter is to be writable by no account but its owner. In the tester role, from its directory tester/:
authored/write_file/run test
authored/write_file/run clean
- The test tester/authored/emplace_bio 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 expected/page.html, 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 check_only 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 its constraints do not list, 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:
+ The test tester/authored/emplace_bio 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 expected/page.html, 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 check_only 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:
authored/emplace_bio/run test
@@ -568,7 +588,7 @@
- No instruction names a file. The page's file names come from the user name of the real uid, which the kernel gives the guard and the interpreter, so a person can write only his own page. The directory comes from the constraints, which the administrator installs. A journal describes the whole page: a run that gives no picture removes the picture an earlier run placed.
+ No instruction names a file. The page's file names come from the user name of the real uid, which the kernel gives the guard and the interpreter, so a person can write only his own page. The directory comes from page_directory() among the guard's constraints, which the administrator writes. A journal describes the whole page: a run that gives no picture removes the picture an earlier run placed.
Decoration travels out of band. A run's styles are a list beside its text, not marks in it, and the interpreter turns each style into one fixed tag with no attribute: <strong>, <em>, <u> and <s>, nested in that order. It writes the rest of the page from a fixed form, and escapes every text it is given, so nothing a person gives becomes markup. There is nothing to sanitize: an attribute such as onload, or a <script> element, cannot be expressed in the language.
@@ -607,17 +627,29 @@
The constraints
+
+ The administrator writes the guard's constraints in a file, here bio_constraint.py, and install_gate puts them at the top of the installed guard_emplace_bio.py:
+
- One constraints file serves every account, since the page's name follows from the account. gate_invoking_account lists the accounts that can have a page. text_max_bytes bounds the display name and text together, and picture_max_bytes the picture.
+ One set of constraints serves every account, since the page's name follows from the account. is_allowed_text_size is given the bytes of the display name and text together. page_directory is not a predicate: the guard checks that it gives an absolute directory, and the interpreter places the pages there. To give a page to every member of a group instead:
+
+ def is_allowed_account(user_name):
+ return Constraint.is_group_member(user_name ,"bio")
+
@@ -627,7 +659,7 @@
Only the four instructions, with exactly their arguments; one display name; at most one picture; text only within a paragraph, and no paragraph without text; every style one of the four, none twice; no control character in any text.
- Account A is listed in gate_invoking_account; the text and the picture are within their limits; the constraints give an absolute directory.
+ is_allowed_account allows account A; is_allowed_text_size and is_allowed_picture_size allow the text and the picture; page_directory() gives an absolute directory.
The picture begins with the signature of a PNG or a JPEG file. SVG is refused on purpose: it is a document that can hold script, and run it when opened by itself.
@@ -671,15 +703,15 @@
Installing
- The administrator makes the directory, owned by the web server's account, and installs the gate, as root, with the constraints above in bio.json:
+ The administrator makes the directory, owned by the web server's account, and installs the gate, as root, with the constraints above:
sudo install -d -o http -g http -m 755 /srv/http/bio
- sudo consumer/Man_In_Grey/install_gate install Man_In_Grey_emplace_bio_gate owner http into /usr/local/lib/Man_In_Grey constraints bio.json
+ sudo consumer/Man_In_Grey/install_gate install Man_In_Grey_emplace_bio_gate owner http into /usr/local/lib/Man_In_Grey constraints bio_constraint.py
install_gate: installed /usr/local/lib/Man_In_Grey/Man_In_Grey/
- install_gate: installed /usr/local/lib/Man_In_Grey/Man_In_Grey/constraints_emplace_bio.json from /root/bio.json
+ install_gate: put the constraints of /root/bio_constraint.py in /usr/local/lib/Man_In_Grey/Man_In_Grey/guard_emplace_bio.py
install_gate: installed /usr/local/lib/Man_In_Grey/Man_In_Grey_gate/Man_In_Grey_emplace_bio_gate, owner http:http, mode 6755
install_gate: its flags, as seen by root:
real.uid=0
@@ -711,35 +743,36 @@
/usr/local/lib/Man_In_Grey/Man_In_Grey/Man_In_Grey orchestrate composer gate /usr/local/lib/Man_In_Grey/Man_In_Grey_gate/Man_In_Grey_emplace_bio_gate
- gate: asked: real uid 2006 (Thomas), effective uid 996 (http), journal of 1857 bytes
+ gate: received: real uid 2006 (Thomas), effective uid 996 (http), journal of 1857 bytes
guard: name: emplace_bio
- guard: version: 1
- guard: constraints: /usr/local/lib/Man_In_Grey/Man_In_Grey/constraints_emplace_bio.json
+ guard: version: 3
+ guard: constraints: /usr/local/lib/Man_In_Grey/Man_In_Grey/guard_emplace_bio.py
guard: journal: 10 instructions
guard: check_passed: wellformed
guard: check_passed: constraint
guard: check_passed: content
guard: check_passed: filesystem
- gate: admitted: guard emplace_bio check passed
+ gate: approved: guard emplace_bio check passed
+ gate: interpreting: /usr/local/lib/Man_In_Grey/Man_In_Grey/interpreter_emplace_bio.py
interpreter: emplaced: /srv/http/bio/Thomas.jpg
interpreter: emplaced: /srv/http/bio/Thomas.html
- gate: executed: the journal was executed
+ gate: interpreted: the interpreter finished the journal
An account the constraints do not list is refused before anything is written:
- gate: asked: real uid 2007 (alice), effective uid 996 (http), journal of 1855 bytes
+ gate: received: real uid 2007 (alice), effective uid 996 (http), journal of 1855 bytes
guard: name: emplace_bio
- guard: version: 1
- guard: constraints: /usr/local/lib/Man_In_Grey/Man_In_Grey/constraints_emplace_bio.json
+ guard: version: 3
+ guard: constraints: /usr/local/lib/Man_In_Grey/Man_In_Grey/guard_emplace_bio.py
guard: journal: 10 instructions
guard: check_passed: wellformed
guard: constraint_broken: gate invoking account 'alice' is not allowed to give journals to this gate
gate: refused: by the guard
- The constraints list Alice, and account names are compared exactly, so the account alice is not her. A person who gives constraints <file> to this gate is refused too, since its real and effective ids differ.
+ is_allowed_account allows Alice, and compares names exactly, so the account alice is not her.
@@ -813,6 +846,22 @@
A smaller step gives the first advantage alone: a symbol that names a field of its instruction's value, as {"symbol": 3 ,"field": "uid"}, keeps index symbols and lets one instruction produce several values.
+
+
+ Transactional play on Btrfs
+
+ Execution now stops at the first error, and what the interpreter did before then stays done, except what its instruction language undoes. A journal would better be played as a transaction: either every instruction takes effect, or none does. Btrfs gives a way to do this for every instruction language, without each language undoing its own work.
+
+
+ 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.
+
+
+ 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 /etc, 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, renameat2 with RENAME_EXCHANGE, 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.
+
+
+ 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 CAP_SYS_ADMIN, unless the file system is mounted with user_subvol_rm_allowed. 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.
+
+
diff --git a/tester/authored/emplace_bio/composer/bio.py b/tester/authored/emplace_bio/composer/bio.py
new file mode 100644
index 0000000..ca8e37c
--- /dev/null
+++ b/tester/authored/emplace_bio/composer/bio.py
@@ -0,0 +1,19 @@
+# composer/bio.py - the test's bio page, with every style, text that looks
+# like markup, and the PNG picture.
+
+from pathlib import Path
+
+DISPLAY_NAME = "A. Tester & Co"
+
+BIO = [
+ [("I write " ,[]) ,("compilers" ,["emphasis"]) ,(" and sail " ,[]) ,("boats" ,["strong" ,"underline"]) ,("." ,[])]
+ ,[("Markup in a bio is only text: & a < b. " ,[]) ,("This was struck." ,["strikethrough"])]
+]
+
+def compose(prov ,journal_iface):
+ journal_iface.display_name(DISPLAY_NAME)
+ for paragraph in BIO:
+ journal_iface.paragraph()
+ for content ,style in paragraph:
+ journal_iface.text(content ,style)
+ journal_iface.picture((Path(prov.composer_root_dpath).parent / "picture.png").read_bytes())
diff --git a/tester/authored/emplace_bio/composer_jpeg/bio.py b/tester/authored/emplace_bio/composer_jpeg/bio.py
new file mode 100644
index 0000000..e35fd2c
--- /dev/null
+++ b/tester/authored/emplace_bio/composer_jpeg/bio.py
@@ -0,0 +1,10 @@
+# composer_jpeg/bio.py - the test's bio page with the JPEG picture, which
+# replaces the PNG an earlier run placed.
+
+from pathlib import Path
+
+def compose(prov ,journal_iface):
+ journal_iface.display_name("A. Tester")
+ journal_iface.paragraph()
+ journal_iface.text("Now with a JPEG." ,[])
+ journal_iface.picture((Path(prov.composer_root_dpath).parent / "picture.jpg").read_bytes())
diff --git a/tester/authored/emplace_bio/composer_no_picture/bio.py b/tester/authored/emplace_bio/composer_no_picture/bio.py
new file mode 100644
index 0000000..5ee5bcc
--- /dev/null
+++ b/tester/authored/emplace_bio/composer_no_picture/bio.py
@@ -0,0 +1,7 @@
+# composer_no_picture/bio.py - the test's bio page with no picture, which
+# removes the picture an earlier run placed.
+
+def compose(prov ,journal_iface):
+ journal_iface.display_name("A. Tester")
+ journal_iface.paragraph()
+ journal_iface.text("No picture now." ,["emphasis"])
diff --git a/tester/authored/emplace_bio/expected/page.html b/tester/authored/emplace_bio/expected/page.html
new file mode 100644
index 0000000..75d9700
--- /dev/null
+++ b/tester/authored/emplace_bio/expected/page.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+A. Tester <test> & Co
+
+
+
A. Tester <test> & Co
+
+
I write compilers and sail boats.
+
Markup in a bio is only text: <script>alert(1)</script> & a < b. This was struck.
+
+
diff --git a/tester/authored/emplace_bio/picture.jpg b/tester/authored/emplace_bio/picture.jpg
new file mode 100644
index 0000000..024ecf2
Binary files /dev/null and b/tester/authored/emplace_bio/picture.jpg differ
diff --git a/tester/authored/emplace_bio/picture.png b/tester/authored/emplace_bio/picture.png
new file mode 100644
index 0000000..0598255
Binary files /dev/null and b/tester/authored/emplace_bio/picture.png differ
diff --git a/tester/authored/emplace_bio/picture.svg b/tester/authored/emplace_bio/picture.svg
new file mode 100644
index 0000000..4253214
--- /dev/null
+++ b/tester/authored/emplace_bio/picture.svg
@@ -0,0 +1 @@
+
diff --git a/tester/authored/emplace_bio/run b/tester/authored/emplace_bio/run
new file mode 100755
index 0000000..92b010e
--- /dev/null
+++ b/tester/authored/emplace_bio/run
@@ -0,0 +1,292 @@
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+run
+
+The emplace_bio test of Man_In_Grey's example 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_emplace_bio.py, placing pages in its scratchpad, and runs that copy
+through the emplace_bio gate, not setuid. It checks:
+
+ page orchestrate composer/: .html is compared with
+ expected/page.html, every text in it escaped, every style one tag,
+ and .png is the given picture
+ jpeg orchestrate composer_jpeg/: the JPEG is placed as .jpg, and
+ the PNG an earlier run placed is removed
+ none orchestrate composer_no_picture/: the page shows no picture, and the
+ JPEG is removed
+ check_only orchestrate composer/ with check_only: approved, and nothing changes
+
+and, with journals made without the journal interface and given to the gate
+directly, as a program other than the orchestrator could:
+
+ name a text instruction with a file name among its arguments: refused by
+ the wellformed check, since the language has no way to name a file
+ style a style the language does not have: refused by the wellformed check
+ order text before any paragraph: refused by the wellformed check
+ svg an SVG picture: refused by the content check
+ long more text than is_allowed_text_size allows: refused by the constraint check
+ account an account is_allowed_account does not allow: refused by the constraint check
+ link a page that has been made a symbolic link: refused by the filesystem check
+
+A refused run is to leave every file as it was.
+
+The test reads consumer/ and writes only in its scratchpad directory,
+$REPO_HOME/tester/scratchpad/emplace_bio, where it runs its copy of
+Man_In_Grey from tree/.
+
+Usage: run
+ 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 ,stat ,json ,shutil ,subprocess ,getpass ,urllib.parse
+sys.dont_write_bytecode = True
+from pathlib import Path
+
+TEST_NAME = "emplace_bio"
+TEST_DPATH = Path(__file__).resolve().parent
+GATE_NAME = "Man_In_Grey_emplace_bio_gate"
+
+
+# --------------------------------------------------------------------------------
+# 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 snapshot(root):
+ "every entry in root, by name, with its content or link target, and its mode"
+ result = {}
+ for path in sorted(root.iterdir()):
+ st = path.lstat()
+ content = os.readlink(path).encode() if stat.S_ISLNK(st.st_mode) else path.read_bytes()
+ result[path.name] = (content ,st.st_mode)
+ return result
+
+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 ,account ,text_max_bytes=8192):
+ "the test's constraints in its copy of guard_emplace_bio.py, giving account a page in work_dpath/bio"
+ text = (
+ "# written by the emplace_bio test\n\n"
+ "def is_allowed_account(user_name):\n"
+ f" return user_name == {account!r}\n\n"
+ "def is_allowed_text_size(byte_count):\n"
+ f" return byte_count <= {text_max_bytes}\n\n"
+ "def is_allowed_picture_size(byte_count):\n"
+ " return byte_count <= 1 << 20\n\n"
+ "def page_directory():\n"
+ f" return {str(work_dpath / 'bio')!r}\n"
+ )
+ put_constraint(tree / "Man_In_Grey" / "guard_emplace_bio.py" ,text)
+
+def run_Man_In_Grey(program ,gate ,path ,work_dpath ,*option):
+ command = [str(program) ,"orchestrate" ,str(path) ,"gate" ,str(gate) ,*option]
+ result = subprocess.run(command ,cwd=work_dpath ,capture_output=True ,text=True)
+ return result.returncode ,(result.stdout + result.stderr).strip()
+
+def run_gate(gate ,journal_bytes):
+ "a journal given to the gate directly, with no orchestrator"
+ result = subprocess.run([str(gate) ,"admit"] ,input=journal_bytes ,capture_output=True)
+ return result.returncode ,(result.stdout + result.stderr).decode("utf-8" ,"replace").strip()
+
+def hand_made(Journal ,instruction_list):
+ "a journal of the emplace_bio language holding the instructions given as (name ,argument)"
+ journal = Journal.Journal("emplace_bio")
+ for name ,argument in instruction_list:
+ journal.place(name ,argument)
+ return journal.encode()
+
+def good_page(*extra):
+ return [("display_name" ,{"text": "A. Tester"}) ,("paragraph" ,{}) ,("text" ,{"content": "Hello." ,"style": []}) ,*extra]
+
+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"
+ gate = tree / "Man_In_Grey_gate" / GATE_NAME
+ print(f"run test in {TEST_DPATH}")
+ print(f" program {program}")
+ print(f" gate {gate}")
+ print(f" writes {work_dpath}")
+ log = Log()
+
+ promoted = os.access(consumer / "Man_In_Grey" / "Man_In_Grey" ,os.X_OK) and os.access(consumer / "Man_In_Grey_gate" / GATE_NAME ,os.X_OK)
+ if not log.check(promoted ,"Man_In_Grey and the emplace_bio gate are promoted"
+ ,None if promoted else f"the developer promotes Man_In_Grey and {GATE_NAME} first"):
+ return log.summary()
+ sys.path.insert(0 ,str(tree / "Man_In_Grey"))
+ import Journal
+
+ if work_dpath.exists():
+ shutil.rmtree(work_dpath)
+ (work_dpath / "bio").mkdir(parents=True)
+ make_tree(consumer ,tree)
+ person = getpass.getuser()
+ write_constraint(tree ,work_dpath ,person)
+ bio = work_dpath / "bio"
+ page_fpath ,png ,jpg = bio / f"{person}.html" ,bio / f"{person}.png" ,bio / f"{person}.jpg"
+
+ # page
+ status ,output = run_Man_In_Grey(program ,gate ,TEST_DPATH / "composer" ,work_dpath)
+ if not check_status(log ,"page Man_In_Grey exits 0" ,status ,0 ,output):
+ return log.summary()
+ expected = (TEST_DPATH / "expected" / "page.html").read_text(encoding="utf-8").replace("USER_NAME" ,urllib.parse.quote(person))
+ found = page_fpath.read_text(encoding="utf-8") if page_fpath.is_file() else None
+ log.check(found == expected ,f"page {person}.html is expected/page.html"
+ ,None if found == expected else ("not written" if found is None else "content differs"))
+ log.check(found is not None and "