gate logs
authorThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Fri, 6 Nov 2026 13:44:49 +0000 (13:44 +0000)
committerThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Fri, 6 Nov 2026 13:44:49 +0000 (13:44 +0000)
26 files changed:
developer/authored/Man_In_Grey.copy/Constraint.py [new file with mode: 0644]
developer/authored/Man_In_Grey.copy/EmplaceBioLanguage.py [new file with mode: 0644]
developer/authored/Man_In_Grey.copy/FileLanguage.py
developer/authored/Man_In_Grey.copy/Guard.py
developer/authored/Man_In_Grey.copy/Interpreter.py
developer/authored/Man_In_Grey.copy/Journal.py
developer/authored/Man_In_Grey.copy/Man_In_Grey.py
developer/authored/Man_In_Grey.copy/constraints_file.json [deleted file]
developer/authored/Man_In_Grey.copy/guard_emplace_bio.py [new file with mode: 0644]
developer/authored/Man_In_Grey.copy/guard_file.py
developer/authored/Man_In_Grey.copy/install_gate [new file with mode: 0755]
developer/authored/Man_In_Grey.copy/interpreter_emplace_bio.py [new file with mode: 0644]
developer/authored/Man_In_Grey.copy/interpreter_file.py
developer/authored/Man_In_Grey_gate.make/Gate.lib.c
developer/authored/Man_In_Grey_gate.make/Man_In_Grey_emplace_bio_gate.CLI.c [new file with mode: 0644]
developer/authored/Man_In_Grey_gate.make/Man_In_Grey_file_gate.CLI.c
document/Man_In_Grey.html
tester/authored/emplace_bio/composer/bio.py [new file with mode: 0644]
tester/authored/emplace_bio/composer_jpeg/bio.py [new file with mode: 0644]
tester/authored/emplace_bio/composer_no_picture/bio.py [new file with mode: 0644]
tester/authored/emplace_bio/expected/page.html [new file with mode: 0644]
tester/authored/emplace_bio/picture.jpg [new file with mode: 0644]
tester/authored/emplace_bio/picture.png [new file with mode: 0644]
tester/authored/emplace_bio/picture.svg [new file with mode: 0644]
tester/authored/emplace_bio/run [new file with mode: 0755]
tester/authored/write_file/run

diff --git a/developer/authored/Man_In_Grey.copy/Constraint.py b/developer/authored/Man_In_Grey.copy/Constraint.py
new file mode 100644 (file)
index 0000000..9219286
--- /dev/null
@@ -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 (file)
index 0000000..e0db6cc
--- /dev/null
@@ -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
+<user_name>.html in a directory its web server serves, with the picture
+beside it as <user_name>.png or <user_name>.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 = [
+    "<!DOCTYPE html>\n"
+    ,"<html>\n<head>\n"
+    ,'<meta charset="utf-8">\n'
+    ,'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; img-src \'self\'">\n'
+    ,'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
+    ,"<title>" ,name ,"</title>\n"
+    ,"</head>\n<body>\n"
+    ,"<h1>" ,name ,"</h1>\n"
+  ]
+  if picture_name:
+    part += ['<img src="' ,html.escape(urllib.parse.quote(picture_name) ,quote=True) ,'" alt="' ,name ,'">\n']
+  for run_list in paragraph_list:
+    part.append("<p>")
+    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("</p>\n")
+  part.append("</body>\n</html>\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)
index 3802c92..14d9344 100644 (file)
@@ -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:
index c980660..ec2d5bc 100644 (file)
@@ -9,7 +9,7 @@ given to report as a tag and a message: <check>_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.
   """
index c76e11a..e1c95b0 100644 (file)
@@ -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)
index 348441c..fd93f7f 100644 (file)
@@ -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 "")
index 86453ba..e612eb6 100755 (executable)
@@ -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 <command> <path> [option ...]
   help | -h                  print this message (also printed when given no arguments)
@@ -21,8 +23,6 @@ Usage: Man_In_Grey <command> <path> [option ...]
 Options:
   gate <file>                the gate to invoke; by default
                              ../Man_In_Grey_gate/Man_In_Grey_file_gate
-  constraints <file>         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 (file)
index 134b3c9..0000000
+++ /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 (file)
index 0000000..963b90b
--- /dev/null
@@ -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 <tag>: <message>; 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 <command>
+  help | -h   print this message (also printed when given no arguments)
+  check       check the journal on standard input
+
+Exit status: 0 when every check passed, 1 when a check refused the journal,
+2 when the journal could not be checked.
+"""
+
+# ------------------------------------------------------------------------------
+# The constraints: the predicates this guard checks a journal with. They are
+# written by whoever installs the gate, between the two marks below, and
+# install_gate can put them in from a file and keeps them when it installs
+# the guard again. A predicate allows only by answering True. As promoted
+# with Man_In_Grey, is_allowed_account refuses every account.
+#
+# They are called only after the modules below are checked and imported, so
+# they can call Constraint, the library of predicates that recur, as
+# Constraint.is_group_member(user_name ,"bio").
+
+# constraints begin
+
+def is_allowed_account(user_name):
+  "True when the account, the gate's real uid, can 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)
index fd42913..641c7cf 100644 (file)
@@ -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 <tag>: <message>; 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 <tag>: <message>; 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 <file>,
-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 <command> [option ...]
-  help | -h            print this message (also printed when given no arguments)
-  check                check the journal on standard input
-Options of check:
-  constraints <file>   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 <command>
+  help | -h   print this message (also printed when given no arguments)
+  check       check the journal on standard input
 
 Exit status: 0 when every check passed, 1 when a check refused the journal,
 2 when the journal could not be checked.
 """
 
+# ------------------------------------------------------------------------------
+# The constraints: the predicates this guard checks a journal with. They are
+# written by whoever installs the gate, between the two marks below, and
+# install_gate can put them in from a file and keeps them when it installs
+# the guard again. A predicate allows only by answering True. As promoted
+# with Man_In_Grey, is_allowed_account refuses every account.
+#
+# They are called only after the modules below are checked and imported, so
+# they can call Constraint, the library of predicates that recur, as
+# Constraint.is_group_member(user_name ,"bio").
+
+# constraints begin
+
+def is_allowed_account(user_name):
+  "True when the account, the gate's real uid, can give journals to the gate"
+  return False
+
+def is_allowed_open(directory ,name ,mode ,owner ,permission):
+  "True when the file can be opened in this mode, to be given this owner and permission"
+  return False
+
+def is_allowed_unlink(directory ,name):
+  "True when the file can be removed"
+  return False
+
+# constraints end
+# ------------------------------------------------------------------------------
+
+
 import sys ,os
 sys.dont_write_bytecode = True
 
-import 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 (executable)
index 0000000..bf0d638
--- /dev/null
@@ -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
+<file>, 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 <command> ...
+  help | -h      print this message (also printed when given no arguments)
+  install <gate> owner <account> into <directory> [constraints <file>]
+                 <gate> is a gate's file name in Man_In_Grey_gate/, such as
+                 Man_In_Grey_emplace_bio_gate; <file> 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 <composer> 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 <gate> owner <account> into <directory> [constraints <file>]" ,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 (file)
index 0000000..cc605c8
--- /dev/null
@@ -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 <tag>: <message>, a line for each lasting change it
+makes, such as a file put in place, and none for an instruction as such.
+The gate logs the journal's course itself.
+
+It checks again that the journal is well formed before executing it. It
+does not check the constraints; that is the guard's work, and the gate
+starts the interpreter only on the guard's verdict. It imports the guard,
+guard_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 <command>
+  help | -h   print this message (also printed when given no arguments)
+  execute     execute the journal on standard input
+
+Exit status: 0 when the journal was executed, 1 when execution failed,
+2 when the journal could not be read or is not well formed.
+"""
+
+import sys ,os
+sys.dont_write_bytecode = True
+
+import stat
+from pathlib import Path
+
+HERE_DPATH = Path(__file__).resolve().parent
+MODULE = ("Journal.py" ,"Interpreter.py" ,"Constraint.py" ,"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)
index bf5ea95..078ac8c 100644 (file)
@@ -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 <tag>: <message>.
+effective ids, and with the journal on standard input, once the guard has
+passed the journal. It executes the journal's instructions, and writes to
+standard output, as <tag>: <message>, a line for each lasting change it
+makes, such as a file put in place, and none for an instruction as such.
+The gate logs the journal's course itself.
 
 It checks again that the journal is well formed before executing it. It
 does not check the constraints; that is the guard's work, and the gate
-starts the interpreter only on the guard's verdict.
+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 <command>
   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)
 
 
 # --------------------------------------------------------------------------------
index 6d30c38..e762e2f 100644 (file)
@@ -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
        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 <name> check passed;
+       line to standard output: guard <language> 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 <dirent.h>
   #include <errno.h>
   #include <fcntl.h>
+  #include <grp.h>
   #include <libgen.h>
   #include <limits.h>
   #include <poll.h>
@@ -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 <command> [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 <file>   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 (file)
index 0000000..cdf0fcd
--- /dev/null
@@ -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);
+}
index 282f940..1659b7c 100644 (file)
@@ -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"
 
index 7920375..accd582 100644 (file)
@@ -84,6 +84,9 @@
           <RT·entry key="constraint">
             Any semantic that participates in the <RT·term>guard</RT·term> code's approval process.
           </RT·entry>
+          <RT·entry key="constraint predicate">
+            A function written at the top of a guard, between the lines <RT·code># constraints begin</RT·code> and <RT·code># constraints end</RT·code>, that answers whether a journal's request is allowed, such as <RT·code>is_allowed_account(user_name)</RT·code>. See <a href="#sec-guard"><RT·Counter·read snapshot="sec-guard" key="counter count"></RT·Counter·read></a>.
+          </RT·entry>
           <RT·entry key="interpreter, journal interpreter">
             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.
           </RT·entry>
         </RT·code>
 
         <p>
-          Th <RT·code>admit</RT·code> command takes the journal on standard input, with the options <RT·code>constraints &lt;file&gt;</RT·code> and <RT·code>check_only</RT·code>. When the gate is asked to <RT·code>admit</RT·code> a journal, it:
+          Th <RT·code>admit</RT·code> command takes the journal on standard input, with the option <RT·code>check_only</RT·code>. When the gate is asked to <RT·code>admit</RT·code> a journal, it:
         </p>
 
         <ol>
           <li>starts the interpreter the same way, with the same journal.</li>
         </ol>
         <p>
-          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.
         </p>
         <p>
           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.
           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.
         </p>
         <p>
-          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 <RT·code>constraints &lt;file&gt;</RT·code>, 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.
         </p>
         <RT·section>
           <RT·name>One gate per instruction language</RT·name>
         <RT·section>
           <RT·name>Output rule</RT·name>
           <p>
-            Every line the guard and the interpreter write is copied to the person, marked <RT·code>guard:</RT·code> or <RT·code>interpreter:</RT·code>, and to the system log under the name <RT·code>Man_In_Grey_gate</RT·code>, beside the gate's own lines, marked <RT·code>gate:</RT·code>, 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 <RT·code>\u{XXXX}</RT·code>, and the wellformed check refuses a directory, file name or owner that holds one.
+            Every line the guard and the interpreter write is copied to the person, marked <RT·code>guard:</RT·code> or <RT·code>interpreter:</RT·code>, and to the system log under the name <RT·code>Man_In_Grey_gate</RT·code>, beside the gate's own lines, marked <RT·code>gate:</RT·code>. The gate logs each journal's course itself, whatever the guard and the interpreter write: <RT·code>received</RT·code>, with account A, account B and the journal's size; then <RT·code>approved</RT·code> or <RT·code>refused</RT·code>; then <RT·code>interpreting</RT·code>; then <RT·code>interpreted</RT·code> or <RT·code>failed</RT·code>. A line breaks the gate's rules when it is not UTF-8, or holds a control character (C0, DEL or C1), or is longer than 4096 bytes, or is one line more than 2000; the gate then stops the program and refuses the journal. Unicode is otherwise welcome. The guard writes any control character it quotes as <RT·code>\u{XXXX}</RT·code>, and the wellformed check refuses a directory, file name or owner that holds one.
           </p>
         </RT·section>
         <RT·section>
           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.
         </p>
         <p>
-          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.
         </p>
         <p>
           The other checks a guard makes belong to its instruction language. The file language's four checks are in <a href="#sec-file-language"><RT·Counter·read snapshot="sec-file-language" key="counter count"></RT·Counter·read></a>.
         </p>
+        <p>
+          A guard's constraints are code. The guard is a Python program, written for one application, so its constraints are Python functions written at the top of the guard itself, where they go with it, and no language for expressing constraints is needed. Each is a <RT·term>constraint predicate</RT·term>, named in the manner of <RT·code>is_allowed_account(user_name)</RT·code> or <RT·code>is_allowed_open(directory ,name ,mode ,owner ,permission)</RT·code>, and the guard refuses a journal when one does not answer <RT·code>True</RT·code>. A predicate can compare with a list, ask whether an account is in a group, or anything else Python can say. The predicates sit between the lines <RT·code># constraints begin</RT·code> and <RT·code># constraints end</RT·code>, so that <RT·code>install_gate</RT·code> can put in an administrator's and keep them when the guard is installed again. As promoted with Man_In_Grey, they refuse every account.
+        </p>
+        <p>
+          Some constraints recur from one guard to the next, and the library module <RT·code>Constraint.py</RT·code> holds them, for a guard's predicates to call:
+        </p>
+        <RT·dictionary key="Predicate" definition="True when">
+          <RT·entry key="is_group_member(user_name ,group_name)">
+            The account is in the group by the group database: listed as a member in <RT·code>/etc/group</RT·code>, or having it as its primary group.
+          </RT·entry>
+          <RT·entry key="has_real_group(group_name)">
+            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 <RT·code>is_group_member</RT·code> already says yes.
+          </RT·entry>
+          <RT·entry key="is_path_under(path ,directory)">
+            The path is the directory or lies within it, both absolute and in normal form.
+          </RT·entry>
+          <RT·entry key="has_permission_within(permission ,limit)">
+            The permission sets no bit the limit does not: <RT·code>0o600</RT·code> is within <RT·code>0o644</RT·code>, and <RT·code>0o4755</RT·code>, setuid, is within neither <RT·code>0o644</RT·code> nor <RT·code>0o755</RT·code>.
+          </RT·entry>
+        </RT·dictionary>
+        <p>
+          The guard checks and imports its modules, <RT·code>Constraint.py</RT·code> among them, only after its predicates are defined, and calls the predicates only after that, so a predicate can call <RT·code>Constraint.is_group_member</RT·code> 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.
+        </p>
         <RT·section>
           <RT·name>Verdict</RT·name>
           <p>
       <RT·section id="sec-interpreter">
         <RT·name>Interpreter</RT·name>
         <p>
-          The gate starts the interpreter only after the guard has approved the journal, with the effective uid of account B and the same journal on its standard input. The interpreter does not check the constraints; that is the guard's work. It 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 <RT·code>page_directory()</RT·code> for the directory its pages go in. It decodes the journal, checks once more that the journal is well formed, and then executes the instructions in order.
         </p>
         <p>
-          The interpreter writes one line to standard output for each thing it does, as <RT·code>&lt;tag&gt;: &lt;message&gt;</RT·code>, 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 <RT·code>&lt;tag&gt;: &lt;message&gt;</RT·code>, the highlights of what it does, as its developer chooses them, and the gate copies each line to the person and to the system log by the output rule. A line for each instruction would be impractical: a language that builds a file from many <RT·code>write</RT·code>s would log each, and a line that echoed an instruction's arguments would quote content, which can be long and does not belong in a log. The interpreters of Man_In_Grey write a line for each lasting change, such as a file put in place or removed, and none for an instruction as such. The gate logs the journal's course itself, so the log shows each journal received, approved and interpreted whatever an interpreter writes. The interpreter's exit status is 0 when it finished the journal, 1 when it stopped partway, and 2 when the journal could not be read or is not well formed.
         </p>
         <p>
-          Execution stops at the first error. What the interpreter did before then stays done, except what its instruction language undoes; for the file language, see <a href="#sec-file-language"><RT·Counter·read snapshot="sec-file-language" key="counter count"></RT·Counter·read></a>.
+          Execution stops at the first error. What the interpreter did before then stays done, except what its instruction language undoes; for the file language, see <a href="#sec-file-language"><RT·Counter·read snapshot="sec-file-language" key="counter count"></RT·Counter·read></a>. Playing a journal as a transaction is a possible future feature; see <a href="#sec-transaction"><RT·Counter·read snapshot="sec-transaction" key="counter count"></RT·Counter·read></a>.
         </p>
       </RT·section>
 
             There is no <RT·code>close</RT·code>. Each opened file is written to a temporary file in its own directory, and after the journal's last instruction every opened file is given its owner and permission and put in place, each in one step: a rename for <RT·code>replace</RT·code>, a hard link that refuses an existing name for <RT·code>create</RT·code>, and for <RT·code>replace_keep</RT·code> a hard link of the old file to its backup name followed by the rename, so the file's name is never absent. Every directory is opened without following a symbolic link, and names are worked on inside the open directory.
           </p>
           <p>
-            Execution stops at the first error, and every temporary file not yet put in place is removed. An <RT·code>unlink</RT·code> earlier in the journal has already happened by then. The interpreter writes a line for each thing it does, such as <RT·code>replaced: /etc/unbound/unbound-US.conf (kept as unbound-US.conf.20261105T083807Z)</RT·code>.
+            Execution stops at the first error, and every temporary file not yet put in place is removed. An <RT·code>unlink</RT·code> earlier in the journal has already happened by then. The interpreter writes a line for each file it puts in place or removes, such as <RT·code>replaced: /etc/unbound/unbound-US.conf (kept as unbound-US.conf.20261105T083807Z)</RT·code>, and none for an <RT·code>open</RT·code> or a <RT·code>write</RT·code>.
           </p>
           <p>
             The guard checks a journal in four steps, each over every instruction, and stops at the first step that finds a problem. It reports each problem as <RT·code>&lt;step&gt;_broken</RT·code> and each step that passes as <RT·code>check_passed</RT·code>:
               Known instructions with exactly their arguments, of the right kinds; absolute directories in normal form; bare file names; no control character in a directory, file name or owner; every <RT·code>write</RT·code> naming the symbol of an <RT·code>open</RT·code>.
             </RT·entry>
             <RT·entry key="constraint">
-              Account A is listed in <RT·code>gate_invoking_account</RT·code>; every file opened or unlinked is listed, in that mode, with that owner and permission.
+              <RT·code>is_allowed_account</RT·code> allows account A; <RT·code>is_allowed_open</RT·code> allows each file opened, in its mode, with its owner and permission; <RT·code>is_allowed_unlink</RT·code> allows each file removed.
             </RT·entry>
             <RT·entry key="validity">
               Every owner names an account.
             </RT·entry>
           </RT·dictionary>
           <p>
-            The constraints of the file language are a JSON file:
+            The constraints at the top of the file language's guard, <RT·code>guard_file.py</RT·code>, allowing Thomas to keep one unbound configuration file and to remove another:
           </p>
           <RT·code>
-            {
-              "gate_invoking_account": ["Thomas"],
-              "file": [
-                {"directory": "/etc/unbound", "name": "unbound-US.conf",
-                 "mode": ["replace_keep"], "owner": "root", "permission": "0644"},
-                {"directory": "/etc/unbound", "name": "unbound-old.conf",
-                 "mode": ["unlink"], "owner": "root", "permission": "0644"}
-              ]
-            }
+            def is_allowed_account(user_name):
+              return user_name == "Thomas"
+
+            def is_allowed_open(directory ,name ,mode ,owner ,permission):
+              return (directory ,name ,mode ,owner ,permission) == ("/etc/unbound" ,"unbound-US.conf" ,"replace_keep" ,"root" ,0o644)
+
+            def is_allowed_unlink(directory ,name):
+              return (directory ,name) == ("/etc/unbound" ,"unbound-old.conf")
           </RT·code>
           <p>
-            <RT·code>mode</RT·code> lists the open modes allowed for the file, and <RT·code>unlink</RT·code> when it can be removed. <RT·code>gate_invoking_account</RT·code> 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 <RT·code>is_allowed_open</RT·code> as an integer, so <RT·code>0o644</RT·code> and <RT·code>"0644"</RT·code> in a composer both arrive as <RT·code>0o644</RT·code>.
           </p>
         </RT·section>
       </RT·section>
             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
             <RT·entry key="gate &lt;file&gt;">
               The gate to invoke, which also sets the instruction language the composers write in. Default: <RT·code>../Man_In_Grey_gate/Man_In_Grey_file_gate</RT·code> from <RT·code>Man_In_Grey.py</RT·code>.
             </RT·entry>
-            <RT·entry key="constraints &lt;file&gt;">
-              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.
-            </RT·entry>
             <RT·entry key="print">
               Print the journal after composing.
             </RT·entry>
             It copies <RT·code>Man_In_Grey/</RT·code> and the one gate named from <RT·code>Man_In_Grey_gate/</RT·code> 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 <RT·code>4755</RT·code>, the mode <RT·code>755</RT·code> with the setuid bit, <RT·code>4000</RT·code>, added. For any other account B it is <RT·code>6755</RT·code>, with the setgid bit, <RT·code>2000</RT·code>, 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.
           </p>
           <p>
-            The constraints given with <RT·code>constraints &lt;file&gt;</RT·code> are installed as <RT·code>constraints_&lt;language&gt;.json</RT·code>. Without the option, an installed constraints file is kept, and a first installation gets the one promoted with Man_In_Grey, which refuses everything. <RT·code>install_gate</RT·code> 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 <RT·code>nosuid</RT·code>, where the kernel ignores the setuid and setgid bits, as it does on removable drives and some separately mounted <RT·code>/home</RT·code> 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 <RT·code>constraints &lt;file&gt;</RT·code>, a Python file of predicates, <RT·code>install_gate</RT·code> 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. <RT·code>install_gate</RT·code> 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 <RT·code>nosuid</RT·code>, where the kernel ignores the setuid and setgid bits, as it does on removable drives and some separately mounted <RT·code>/home</RT·code> 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.
           </p>
         </RT·section>
 
         <RT·section>
           <RT·name>Testing</RT·name>
           <p>
-            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 <RT·code>tester/authored/write_file</RT·code> runs the file language's gate, and checks twenty-five things. Two runs of composers using every mode and <RT·code>unlink</RT·code> are compared with <RT·code>expected/run_1</RT·code> and <RT·code>expected/run_2</RT·code>, with backup times replaced by the letters <RT·code>YYYYMMDDTHHMMSSZ</RT·code>. A file opened in <RT·code>create</RT·code> mode is written once and refused the second time. A journal with one allowed file and one the constraints do not list is refused whole. A composer giving a wrong mode fails before any journal reaches the gate. A composer directory holding a composer other accounts can write is refused by the orchestrator. A <RT·code>check_only</RT·code> run is approved by the guard and changes nothing. A run under a file size limit the gate cannot raise is refused before the guard starts. Each refusal leaves every file as it was. Since the gate checks ownership on every run, every directory from <RT·code>/</RT·code> down to the promoted interpreter is to be writable by no account but its owner. In the tester role, from its directory <RT·code>tester/</RT·code>:
+            The tester has two tests. Each copies the promoted Man_In_Grey into its scratchpad directory, puts constraints of its own at the top of the copy's guard, and runs the copy through a gate that is not setuid, so accounts A and B are the same. The gate checks ownership on every run, so the test makes its copy writable by its owner alone, and every directory from <RT·code>/</RT·code> down to the scratchpad directory is to be writable by no account but its owner too. The test <RT·code>tester/authored/write_file</RT·code> runs the file language's gate, and checks twenty-five things. Two runs of composers using every mode and <RT·code>unlink</RT·code> are compared with <RT·code>expected/run_1</RT·code> and <RT·code>expected/run_2</RT·code>, with backup times replaced by the letters <RT·code>YYYYMMDDTHHMMSSZ</RT·code>. A file opened in <RT·code>create</RT·code> mode is written once and refused the second time. A journal with one allowed file and one the constraints do not list is refused whole. A composer giving a wrong mode fails before any journal reaches the gate. A composer directory holding a composer other accounts can write is refused by the orchestrator. A <RT·code>check_only</RT·code> run is approved by the guard and changes nothing. A run under a file size limit the gate cannot raise is refused before the guard starts. Each refusal leaves every file as it was. Since the gate checks ownership on every run, every directory from <RT·code>/</RT·code> down to the promoted interpreter is to be writable by no account but its owner. In the tester role, from its directory <RT·code>tester/</RT·code>:
           </p>
           <RT·code>
             authored/write_file/run test
             authored/write_file/run clean
           </RT·code>
           <p>
-            The test <RT·code>tester/authored/emplace_bio</RT·code> runs the example language's gate, and checks twenty-six things. A page with every style and with text that looks like markup is compared with <RT·code>expected/page.html</RT·code>, and its picture with the one given. A later run with a JPEG places it and removes the PNG, and a run with no picture removes the JPEG. A <RT·code>check_only</RT·code> run changes nothing. Seven journals are then made without the journal interface and given to the gate directly, as a program other than the orchestrator could: one whose text instruction carries a file name, one with a style the language does not have, one with text before any paragraph, one with an SVG picture, one with more text than its constraints allow, one from an account 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 <RT·code>tester/authored/emplace_bio</RT·code> runs the example language's gate, and checks twenty-six things. A page with every style and with text that looks like markup is compared with <RT·code>expected/page.html</RT·code>, and its picture with the one given. A later run with a JPEG places it and removes the PNG, and a run with no picture removes the JPEG. A <RT·code>check_only</RT·code> run changes nothing. Seven journals are then made without the journal interface and given to the gate directly, as a program other than the orchestrator could: one whose text instruction carries a file name, one with a style the language does not have, one with text before any paragraph, one with an SVG picture, one with more text than its constraints allow, one from an account they do not allow, and one for a page that has been made a symbolic link. Each is refused by the check that is to refuse it, and changes nothing:
           </p>
           <RT·code>
             authored/emplace_bio/run test
             </RT·entry>
           </RT·dictionary>
           <p>
-            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 <RT·code>page_directory()</RT·code> 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.
           </p>
           <p>
             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: <RT·code>&lt;strong&gt;</RT·code>, <RT·code>&lt;em&gt;</RT·code>, <RT·code>&lt;u&gt;</RT·code> and <RT·code>&lt;s&gt;</RT·code>, 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 <RT·code>onload</RT·code>, or a <RT·code>&lt;script&gt;</RT·code> element, cannot be expressed in the language.
 
         <RT·section>
           <RT·name>The constraints</RT·name>
+          <p>
+            The administrator writes the guard's constraints in a file, here <RT·code>bio_constraint.py</RT·code>, and <RT·code>install_gate</RT·code> puts them at the top of the installed <RT·code>guard_emplace_bio.py</RT·code>:
+          </p>
           <RT·code>
-            {
-              "gate_invoking_account": ["Thomas", "Alice"],
-              "directory": "/srv/http/bio",
-              "text_max_bytes": 8192,
-              "picture_max_bytes": 1048576
-            }
+            def is_allowed_account(user_name):
+              return user_name in ("Thomas" ,"Alice")
+
+            def is_allowed_text_size(byte_count):
+              return byte_count &lt;= 8192
+
+            def is_allowed_picture_size(byte_count):
+              return byte_count &lt;= 1 &lt;&lt; 20
+
+            def page_directory():
+              return "/srv/http/bio"
           </RT·code>
           <p>
-            One constraints file serves every account, since the page's name follows from the account. <RT·code>gate_invoking_account</RT·code> lists the accounts that can have a page. <RT·code>text_max_bytes</RT·code> bounds the display name and text together, and <RT·code>picture_max_bytes</RT·code> the picture.
+            One set of constraints serves every account, since the page's name follows from the account. <RT·code>is_allowed_text_size</RT·code> is given the bytes of the display name and text together. <RT·code>page_directory</RT·code> 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:
           </p>
+          <RT·code>
+            def is_allowed_account(user_name):
+              return Constraint.is_group_member(user_name ,"bio")
+          </RT·code>
         </RT·section>
 
         <RT·section>
               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.
             </RT·entry>
             <RT·entry key="constraint">
-              Account A is listed in <RT·code>gate_invoking_account</RT·code>; the text and the picture are within their limits; the constraints give an absolute directory.
+              <RT·code>is_allowed_account</RT·code> allows account A; <RT·code>is_allowed_text_size</RT·code> and <RT·code>is_allowed_picture_size</RT·code> allow the text and the picture; <RT·code>page_directory()</RT·code> gives an absolute directory.
             </RT·entry>
             <RT·entry key="content">
               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.
         <RT·section>
           <RT·name>Installing</RT·name>
           <p>
-            The administrator makes the directory, owned by the web server's account, and installs the gate, as root, with the constraints above in <RT·code>bio.json</RT·code>:
+            The administrator makes the directory, owned by the web server's account, and installs the gate, as root, with the constraints above:
           </p>
           <RT·code>
             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
           </RT·code>
           <RT·code>
             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
             /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
           </RT·code>
           <RT·code>
-            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
           </RT·code>
           <p>
             An account the constraints do not list is refused before anything is written:
           </p>
           <RT·code>
-            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
           </RT·code>
           <p>
-            The constraints list <RT·code>Alice</RT·code>, and account names are compared exactly, so the account <RT·code>alice</RT·code> is not her. A person who gives <RT·code>constraints &lt;file&gt;</RT·code> to this gate is refused too, since its real and effective ids differ.
+            <RT·code>is_allowed_account</RT·code> allows <RT·code>Alice</RT·code>, and compares names exactly, so the account <RT·code>alice</RT·code> is not her.
           </p>
         </RT·section>
 
             A smaller step gives the first advantage alone: a symbol that names a field of its instruction's value, as <RT·code>{"symbol": 3 ,"field": "uid"}</RT·code>, keeps index symbols and lets one instruction produce several values.
           </p>
         </RT·section>
+
+        <RT·section id="sec-transaction">
+          <RT·name>Transactional play on Btrfs</RT·name>
+          <p>
+            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.
+          </p>
+          <p>
+            A Btrfs subvolume is a directory tree that can be snapshotted. Making a snapshot takes about as long as making a directory, whatever the size of the tree, because the snapshot shares every block with its source; a block is copied only when one side writes it. Before playing a journal, the interpreter would snapshot each subvolume the journal writes in. When the play succeeds, it deletes the snapshots. When the play fails, it puts back each file it changed from the snapshot, then deletes the snapshots. It knows which files those are, since it touched them.
+          </p>
+          <p>
+            Putting back files one at a time is the general case. A running system cannot replace the subvolume it runs from, such as the one holding <RT·code>/etc</RT·code>, so there the rollback is a restore of each changed file, each in one step, by the same rename the file language uses to put a file in place. Where the destination is a subvolume of its own that is not mounted separately and that no program depends on keeping open, such as a web server's page directory, the interpreter could instead play the journal into a snapshot and then exchange the snapshot and the subvolume in one call, <RT·code>renameat2</RT·code> with <RT·code>RENAME_EXCHANGE</RT·code>, so the whole change appears at once or not at all. Whether Btrfs allows that exchange between subvolumes is to be confirmed before the design relies on it.
+          </p>
+          <p>
+            The cost is privilege. Making a snapshot needs ownership of the subvolume, or root, and write access to the directory the snapshot goes in. Deleting one needs <RT·code>CAP_SYS_ADMIN</RT·code>, unless the file system is mounted with <RT·code>user_subvol_rm_allowed</RT·code>. A gate whose account B is not root would need the second, and a gate holding only the capabilities its language needs would need the first, which is nearly root by itself. On other file systems the play stays as it is, and a language undoes its own work where it can, as the file language does by putting its files in place only after its last instruction.
+          </p>
+        </RT·section>
       </RT·section>
 
       <RT·section id="sec-open">
diff --git a/tester/authored/emplace_bio/composer/bio.py b/tester/authored/emplace_bio/composer/bio.py
new file mode 100644 (file)
index 0000000..ca8e37c
--- /dev/null
@@ -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 <test> & Co"
+
+BIO = [
+  [("I write " ,[]) ,("compilers" ,["emphasis"]) ,(" and sail " ,[]) ,("boats" ,["strong" ,"underline"]) ,("." ,[])]
+  ,[("Markup in a bio is only text: <script>alert(1)</script> & 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 (file)
index 0000000..e35fd2c
--- /dev/null
@@ -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 (file)
index 0000000..5ee5bcc
--- /dev/null
@@ -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 (file)
index 0000000..75d9700
--- /dev/null
@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self'">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>A. Tester &lt;test&gt; &amp; Co</title>
+</head>
+<body>
+<h1>A. Tester &lt;test&gt; &amp; Co</h1>
+<img src="USER_NAME.png" alt="A. Tester &lt;test&gt; &amp; Co">
+<p>I write <em>compilers</em> and sail <strong><u>boats</u></strong>.</p>
+<p>Markup in a bio is only text: &lt;script&gt;alert(1)&lt;/script&gt; &amp; a &lt; b. <s>This was struck.</s></p>
+</body>
+</html>
diff --git a/tester/authored/emplace_bio/picture.jpg b/tester/authored/emplace_bio/picture.jpg
new file mode 100644 (file)
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 (file)
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 (file)
index 0000000..4253214
--- /dev/null
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><circle r="10"/></svg>
diff --git a/tester/authored/emplace_bio/run b/tester/authored/emplace_bio/run
new file mode 100755 (executable)
index 0000000..92b010e
--- /dev/null
@@ -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/: <user_name>.html is compared with
+             expected/page.html, every text in it escaped, every style one tag,
+             and <user_name>.png is the given picture
+  jpeg       orchestrate composer_jpeg/: the JPEG is placed as <user_name>.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 <command>
+  help | -h | --help   print this message (also printed when given no arguments)
+  test                 run the test; the exit status is 0 only when every check passes
+  clean                delete the test's scratchpad directory
+"""
+
+import sys ,os ,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 "<script" not in found and "&lt;script&gt;" in found ,"page text that looks like markup is escaped")
+  log.check(png.is_file() and png.read_bytes() == (TEST_DPATH / "picture.png").read_bytes() ,f"page {person}.png is the given picture")
+  modes = [stat.S_IMODE(p.stat().st_mode) for p in (page_fpath ,png) if p.exists()]
+  log.check(modes == [0o644 ,0o644] ,"page files have permission 0644" ,None if modes == [0o644 ,0o644] else f"{[oct(m) for m in modes]}")
+
+  # jpeg, then no picture
+  status ,output = run_Man_In_Grey(program ,gate ,TEST_DPATH / "composer_jpeg" ,work_dpath)
+  check_status(log ,"jpeg Man_In_Grey exits 0" ,status ,0 ,output)
+  log.check(jpg.is_file() and not png.exists() and f'src="{urllib.parse.quote(person)}.jpg"' in page_fpath.read_text()
+            ,"jpeg places the JPEG and removes the PNG")
+  status ,output = run_Man_In_Grey(program ,gate ,TEST_DPATH / "composer_no_picture" ,work_dpath)
+  check_status(log ,"none Man_In_Grey exits 0" ,status ,0 ,output)
+  log.check(not jpg.exists() and not png.exists() and "<img" not in page_fpath.read_text() ,"none removes the picture")
+
+  # check_only
+  before = snapshot(bio)
+  status ,output = run_Man_In_Grey(program ,gate ,TEST_DPATH / "composer" ,work_dpath ,"check_only")
+  check_status(log ,"check_only is approved by the guard" ,status ,0 ,output ,"approved: guard emplace_bio check passed")
+  log.check(snapshot(bio) == before ,"check_only changes nothing")
+
+  # journals given to the gate directly
+  svg = (TEST_DPATH / "picture.svg").read_bytes()
+  case_list = (
+    ("name" ,good_page(("text" ,{"content": "x" ,"style": [] ,"name": "/etc/passwd"})) ,{} ,"wellformed_broken:")
+    ,("style" ,good_page(("text" ,{"content": "x" ,"style": ["blink"]})) ,{} ,"wellformed_broken:")
+    ,("order" ,[("display_name" ,{"text": "A. Tester"}) ,("text" ,{"content": "x" ,"style": []}) ,("paragraph" ,{}) ,("text" ,{"content": "y" ,"style": []})]
+      ,{} ,"wellformed_broken:")
+    ,("svg" ,good_page(("picture" ,{"content": svg})) ,{} ,"content_broken:")
+    ,("long" ,good_page() ,{"text_max_bytes": 10} ,"constraint_broken:")
+    ,("account" ,good_page() ,{"account": "nobody_at_all"} ,"constraint_broken:")
+  )
+  for name ,instruction_list ,variation ,want in case_list:
+    write_constraint(tree ,work_dpath ,variation.get("account" ,person) ,variation.get("text_max_bytes" ,8192))
+    before = snapshot(bio)
+    status ,output = run_gate(gate ,hand_made(Journal ,instruction_list))
+    check_status(log ,f"{name} is refused by the {want[:-len('_broken:')]} check" ,status ,1 ,output ,want)
+    log.check(snapshot(bio) == before ,f"{name} changes nothing")
+  write_constraint(tree ,work_dpath ,person)
+
+  # a page made a symbolic link
+  target = work_dpath / "elsewhere.html"
+  target.write_text("not to be overwritten\n")
+  page_fpath.unlink()
+  page_fpath.symlink_to(target)
+  before = snapshot(bio)
+  status ,output = run_gate(gate ,hand_made(Journal ,good_page()))
+  check_status(log ,"link is refused by the filesystem check" ,status ,1 ,output ,"filesystem_broken:")
+  log.check(snapshot(bio) == before and target.read_text() == "not to be overwritten\n" ,"link changes nothing")
+
+  return log.summary()
+
+def clean(repo_home):
+  work_dpath = repo_home / "tester" / "scratchpad" / TEST_NAME
+  print(f"run clean   in {TEST_DPATH}")
+  if work_dpath.exists():
+    shutil.rmtree(work_dpath)
+    print(f"  deleted   {work_dpath}")
+  else:
+    print(f"  kept      {work_dpath}  (there is nothing to delete)")
+  return 0
+
+
+# --------------------------------------------------------------------------------
+# command line
+
+def print_help():
+  print(__doc__.strip())
+  return 0
+
+def CLI(argv=None):
+  if argv is None: argv = sys.argv[1:]
+  if not argv or argv[0] in ("help" ,"-h" ,"--help"): return print_help()
+  command ,rest = argv[0] ,argv[1:]
+  if command not in ("test" ,"clean"):
+    print(f"Error: unknown command '{command}'." ,file=sys.stderr)
+    print_help()
+    return 1
+  if rest:
+    print(f"Error: {command} is given no arguments." ,file=sys.stderr)
+    return 1
+  if not os.environ.get("REPO_HOME"):
+    print("Error: REPO_HOME is not set; enter the tester role with ./setup tester." ,file=sys.stderr)
+    return 1
+  repo_home = Path(os.environ["REPO_HOME"]).resolve()
+  return test(repo_home) if command == "test" else clean(repo_home)
+
+
+if __name__ == "__main__":
+  try:
+    sys.exit(CLI())
+  except BrokenPipeError:
+    os.dup2(os.open(os.devnull ,os.O_WRONLY) ,sys.stdout.fileno())
+    sys.exit(1)
index 41a44e3..27d006a 100755 (executable)
@@ -3,9 +3,11 @@
 """
 run
 
-The write_file test of Man_In_Grey's file instruction language. It runs the
-promoted Man_In_Grey, from $REPO_HOME/consumer, through a gate that is not
-setuid, with constraints it writes for the files it expects, and checks:
+The write_file test of Man_In_Grey's file instruction language. It copies
+the promoted Man_In_Grey, from $REPO_HOME/consumer, into its scratchpad
+directory, puts constraints of its own at the top of the copy's
+guard_file.py, allowing the files it expects, and runs that copy through
+the file language's gate, not setuid. It checks:
 
   run_1      orchestrate composer/: each composer's file written, and
              out/obsolete.conf, made by the test beforehand, unlinked; the
@@ -31,7 +33,8 @@ of the run, so names are compared with the time replaced by the letters
 YYYYMMDDTHHMMSSZ. Content, mode and owner are compared too.
 
 The test reads consumer/ and writes only in its scratchpad directory,
-$REPO_HOME/tester/scratchpad/write_file, where it runs Man_In_Grey.
+$REPO_HOME/tester/scratchpad/write_file, where it runs its copy of
+Man_In_Grey from tree/.
 
 Usage: run <command>
   help | -h | --help   print this message (also printed when given no arguments)
@@ -153,8 +156,8 @@ def limit_file_size():
   "run in the child before Man_In_Grey starts: a file size limit, soft and hard, of 1 MiB"
   resource.setrlimit(resource.RLIMIT_FSIZE ,(1 << 20 ,1 << 20))
 
-def run_Man_In_Grey(program ,path ,work_dpath ,constraint_fpath ,*option ,preexec=None):
-  command = [str(program) ,"orchestrate" ,str(path) ,"constraints" ,str(constraint_fpath) ,*option]
+def run_Man_In_Grey(program ,path ,work_dpath ,*option ,preexec=None):
+  command = [str(program) ,"orchestrate" ,str(path) ,*option]
   result = subprocess.run(command ,cwd=work_dpath ,capture_output=True ,text=True ,preexec_fn=preexec)
   return result.returncode ,(result.stdout + result.stderr).strip()
 
@@ -170,42 +173,77 @@ def check_status(log ,what ,status ,want ,output ,want_text=None):
     log.note(output)
   return passed
 
-def write_constraint(work_dpath):
+def make_tree(consumer ,tree):
+  """
+  A copy of the promoted Man_In_Grey/ and Man_In_Grey_gate/, side by side, in
+  tree. The gate refuses a guard in a directory other accounts can write,
+  so tree and the directory holding it are made writable by the owner alone.
+  """
+  tree.mkdir(parents=True)
+  for each in (tree.parent ,tree):
+    os.chmod(each ,0o755)
+  for name in ("Man_In_Grey" ,"Man_In_Grey_gate"):
+    (tree / name).mkdir()
+    os.chmod(tree / name ,0o755)
+    for source in sorted((consumer / name).iterdir()):
+      if source.is_file():
+        shutil.copy2(source ,tree / name / source.name)
+
+def put_constraint(guard_fpath ,constraint):
+  "replace the predicates between the guard's constraint marks with constraint"
+  line_list = guard_fpath.read_text(encoding="utf-8").splitlines()
+  begin ,end = line_list.index("# constraints begin") ,line_list.index("# constraints end")
+  text = "\n".join(line_list[:begin + 1] + ["" ,constraint.strip("\n") ,""] + line_list[end:]) + "\n"
+  guard_fpath.unlink()
+  guard_fpath.write_text(text ,encoding="utf-8")
+  os.chmod(guard_fpath ,0o644)
+
+def write_constraint(tree ,work_dpath):
+  "the test's constraints in its copy of guard_file.py, allowing the files in ALLOWED to the person running the test"
   person = getpass.getuser()
-  constraint = {
-    "gate_invoking_account": [person]
-    ,"file": [
-      {"directory": str(work_dpath / d) ,"name": n ,"mode": m ,"owner": person ,"permission": p}
-      for d ,n ,m ,p in ALLOWED
-    ]
-  }
-  path = work_dpath / "constraints.json"
-  path.write_text(json.dumps(constraint ,indent=2) + "\n" ,encoding="utf-8")
-  return path
+  allowed = {(str(work_dpath / d) ,n): (m ,int(p ,8)) for d ,n ,m ,p in ALLOWED}
+  text = (
+    "# written by the write_file test\n\n"
+    f"PERSON = {person!r}\n"
+    f"ALLOWED = {allowed!r}\n\n"
+    "def is_allowed_account(user_name):\n"
+    "  return user_name == PERSON\n\n"
+    "def is_allowed_open(directory ,name ,mode ,owner ,permission):\n"
+    "  entry = ALLOWED.get((directory ,name))\n"
+    "  return entry is not None and mode in entry[0] and owner == PERSON and permission == entry[1]\n\n"
+    "def is_allowed_unlink(directory ,name):\n"
+    "  entry = ALLOWED.get((directory ,name))\n"
+    "  return entry is not None and \"unlink\" in entry[0]\n"
+  )
+  put_constraint(tree / "Man_In_Grey" / "guard_file.py" ,text)
 
 def test(repo_home):
-  program = repo_home / "consumer" / "Man_In_Grey" / "Man_In_Grey"
+  consumer = repo_home / "consumer"
   work_dpath = (repo_home / "tester" / "scratchpad" / TEST_NAME).resolve()
+  tree = work_dpath / "tree"
+  program = tree / "Man_In_Grey" / "Man_In_Grey"
   print(f"run test   in {TEST_DPATH}")
   print(f"  program  {program}")
   print(f"  writes   {work_dpath}")
   log = Log()
 
-  if not log.check(os.access(program ,os.X_OK) ,"Man_In_Grey is promoted"
-                   ,None if os.access(program ,os.X_OK) else "the developer promotes Man_In_Grey and Man_In_Grey_file_gate first"):
+  promoted = os.access(consumer / "Man_In_Grey" / "Man_In_Grey" ,os.X_OK)
+  if not log.check(promoted ,"Man_In_Grey is promoted"
+                   ,None if promoted else "the developer promotes Man_In_Grey and Man_In_Grey_file_gate first"):
     return log.summary()
 
   if work_dpath.exists():
     shutil.rmtree(work_dpath)
   for rel in OUTPUT_DIR:
     (work_dpath / rel).mkdir(parents=True)
-  constraint_fpath = write_constraint(work_dpath)
+  make_tree(consumer ,tree)
+  write_constraint(tree ,work_dpath)
   (work_dpath / "out" / "obsolete.conf").write_text("to be unlinked\n")
   out = work_dpath / "out"
 
   # run_1 and run_2
   for run_name in ("run_1" ,"run_2"):
-    status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer" ,work_dpath ,constraint_fpath)
+    status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer" ,work_dpath)
     if check_status(log ,f"{run_name} Man_In_Grey exits 0" ,status ,0 ,output):
       compare(log ,run_name ,work_dpath)
     else:
@@ -213,25 +251,25 @@ def test(repo_home):
       return log.summary()
 
   # create: once, then refused
-  status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer_create" / "new.py" ,work_dpath ,constraint_fpath)
+  status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer_create" / "new.py" ,work_dpath)
   check_status(log ,"create Man_In_Grey exits 0" ,status ,0 ,output)
   new = out / "new.conf"
   log.check(new.is_file() and new.read_text() == "made once\n" and stat.S_IMODE(new.stat().st_mode) == 0o600
             ,"create out/new.conf" ,None if new.is_file() else "not written")
   before = snapshot(out)
-  status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer_create" / "new.py" ,work_dpath ,constraint_fpath)
+  status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer_create" / "new.py" ,work_dpath)
   check_status(log ,"create again is refused by the filesystem check" ,status ,1 ,output ,"filesystem_broken:")
   log.check(snapshot(out) == before ,"create again changes nothing")
 
   # a journal the constraints refuse
   before = snapshot(out)
-  status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer_refused" ,work_dpath ,constraint_fpath)
+  status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer_refused" ,work_dpath)
   check_status(log ,"refused is refused by the constraint check" ,status ,1 ,output ,"constraint_broken:")
   log.check(snapshot(out) == before ,"refused changes nothing, the allowed file included")
 
   # a composer with a wrong argument
   before = snapshot(out)
-  status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer_argument" ,work_dpath ,constraint_fpath)
+  status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer_argument" ,work_dpath)
   check_status(log ,"argument fails in the composer" ,status ,2 ,output ,"wrong_mode.py: open: mode 'overwrite'")
   log.check(snapshot(out) == before ,"argument changes nothing")
 
@@ -241,19 +279,19 @@ def test(repo_home):
   shutil.copy(TEST_DPATH / "composer" / "unbound_conf.py" ,writable / "unbound_conf.py")
   os.chmod(writable / "unbound_conf.py" ,0o664)
   before = snapshot(out)
-  status ,output = run_Man_In_Grey(program ,writable ,work_dpath ,constraint_fpath)
+  status ,output = run_Man_In_Grey(program ,writable ,work_dpath)
   check_status(log ,"writable composer is refused by the orchestrator" ,status ,2 ,output ,"writable by group or other accounts")
   log.check(snapshot(out) == before ,"writable changes nothing")
 
   # check_only
   before = snapshot(out)
-  status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer" ,work_dpath ,constraint_fpath ,"check_only")
-  check_status(log ,"check_only is approved by the guard" ,status ,0 ,output ,"admitted: guard file check passed")
+  status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer" ,work_dpath ,"check_only")
+  check_status(log ,"check_only is approved by the guard" ,status ,0 ,output ,"approved: guard file check passed")
   log.check(snapshot(out) == before ,"check_only changes nothing")
 
   # a resource limit the gate cannot raise
   before = snapshot(out)
-  status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer" ,work_dpath ,constraint_fpath ,preexec=limit_file_size)
+  status ,output = run_Man_In_Grey(program ,TEST_DPATH / "composer" ,work_dpath ,preexec=limit_file_size)
   check_status(log ,"limit is refused by the gate" ,status ,2 ,output ,"the file size (bytes) limit is 1048576")
   log.check(snapshot(out) == before ,"limit changes nothing")