.
authorThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Sun, 25 Oct 2026 14:52:41 +0000 (14:52 +0000)
committerThomas Walker Lynch <eknp9n@reasoningtechnology.com>
Sun, 25 Oct 2026 14:52:41 +0000 (14:52 +0000)
22 files changed:
developer/Man_In_Grey_file_gate.make/Man_In_Grey_file_gate.CLI.c [new file with mode: 0644]
developer/authored/Man_In_Grey.copy/FileLanguage.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/journal_interpreter.py
developer/authored/Man_In_Grey_file_gate.make/Man_In_Grey_file_gate.CLI.c [deleted file]
document/design_Man_In_Grey.html
tester/authored/write_file/composer/DNS/unbound.conf.py [new file with mode: 0644]
tester/authored/write_file/composer/obsolete.py [new file with mode: 0644]
tester/authored/write_file/composer/unbound_conf.py [new file with mode: 0644]
tester/authored/write_file/composer/web/site_conf.py [new file with mode: 0644]
tester/authored/write_file/composer_argument/wrong_mode.py [new file with mode: 0644]
tester/authored/write_file/composer_create/new.py [new file with mode: 0644]
tester/authored/write_file/composer_refused/allowed_and_not.py [new file with mode: 0644]
tester/authored/write_file/run
tester/authored/write_file/stage/DNS/unbound.conf.py [deleted file]
tester/authored/write_file/stage/obsolete.py [deleted file]
tester/authored/write_file/stage/unbound_conf.py [deleted file]
tester/authored/write_file/stage/web/site_conf.py [deleted file]
tester/authored/write_file/stage_argument/wrong_mode.py [deleted file]
tester/authored/write_file/stage_create/new.py [deleted file]
tester/authored/write_file/stage_refused/allowed_and_not.py [deleted file]

diff --git a/developer/Man_In_Grey_file_gate.make/Man_In_Grey_file_gate.CLI.c b/developer/Man_In_Grey_file_gate.make/Man_In_Grey_file_gate.CLI.c
new file mode 100644 (file)
index 0000000..eeb0114
--- /dev/null
@@ -0,0 +1,127 @@
+/*
+  Man_In_Grey_file_gate
+
+  The gate of the bespoke journal interpreter for the file command language.
+  Installed setuid root, it starts the journal interpreter with root's rights
+  while the real uid still names the caller. Unblessed, it starts the journal
+  interpreter with the caller's rights.
+
+  The journal interpreter is at a path fixed when the gate is compiled,
+  relative to the gate: ../Man_In_Grey/journal_interpreter_file.py. The caller
+  cannot name another. When running privileged, the gate refuses to start it
+  unless the interpreter, and every directory above it, is owned by root and
+  writable by no other account.
+
+  The journal arrives on standard input and is passed on unread. Every
+  argument is passed on to the journal interpreter, except --print-flags,
+  which reports the caller and whether the gate is running privileged.
+
+  Who is allowed to hand a journal to the interpreter is decided by the
+  interpreter's constraints, not here.
+*/
+
+#include <errno.h>
+#include <libgen.h>
+#include <limits.h>
+#include <pwd.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/prctl.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+#define PYTHON "/usr/bin/python3"
+#define INTERPRETER_RELATIVE "../Man_In_Grey/journal_interpreter_file.py"
+#define ARGUMENT_MAX 64
+
+Local void die(const char *format ,...){
+  va_list ap; va_start(ap ,format);
+  fputs("Man_In_Grey_file_gate: " ,stderr);
+  vfprintf(stderr ,format ,ap); va_end(ap);
+  fputc('\n' ,stderr);
+  _exit(2);
+}
+
+Local bool privileged(void){
+  return geteuid() != getuid() || getegid() != getgid();
+}
+
+/* path and every directory above it: owned by root, writable by no other account */
+Local void check_owned_by_root(const char *path){
+  char prefix[PATH_MAX];
+  size_t length = strlen(path);
+  if(length >= sizeof prefix) die("path too long: %s" ,path);
+  for(size_t i = 1; i <= length; ++i){
+    if( i < length && path[i] != '/' ) continue;
+    memcpy(prefix ,path ,i); prefix[i] = '\0';
+    struct stat st;
+    if( lstat(prefix ,&st) != 0 ) die("cannot examine %s: %s" ,prefix ,strerror(errno));
+    if( st.st_uid != 0 ) die("refusing to run privileged: %s is not owned by root" ,prefix);
+    if( st.st_mode & (S_IWGRP | S_IWOTH) ) die("refusing to run privileged: %s is writable by an account other than root" ,prefix);
+  }
+  struct stat st;
+  if( lstat("/" ,&st) != 0 || st.st_uid != 0 ) die("refusing to run privileged: / is not owned by root");
+}
+
+Local void print_flags(void){
+  struct passwd *pw = getpwuid(getuid());
+  printf("caller.uid=%ld\n" ,(long)getuid());
+  printf("caller.name=%s\n" ,pw ? pw->pw_name : "unknown");
+  printf("effective.uid=%ld\n" ,(long)geteuid());
+  printf("privileged=%d\n" ,privileged() ? 1 : 0);
+}
+
+int main(int argc ,char **argv){
+  if( argc > 1 && strcmp(argv[1] ,"--print-flags") == 0 ){ print_flags(); return 0; }
+  if( argc > ARGUMENT_MAX ) die("too many arguments");
+
+  char exe[PATH_MAX];
+  ssize_t n = readlink("/proc/self/exe" ,exe ,sizeof exe - 1);
+  if( n < 0 ) die("cannot resolve /proc/self/exe: %s" ,strerror(errno));
+  exe[n] = '\0';
+
+  char candidate[PATH_MAX];
+  if( snprintf(candidate ,sizeof candidate ,"%s/%s" ,dirname(exe) ,INTERPRETER_RELATIVE) >= (int)sizeof candidate )
+    die("path too long");
+  char interpreter[PATH_MAX];
+  if( !realpath(candidate ,interpreter) ) die("journal interpreter not found: %s" ,candidate);
+
+  if( privileged() ) check_owned_by_root(interpreter);
+
+  /* harden the process before starting the interpreter */
+  uid_t caller_uid = getuid();
+  gid_t caller_gid = getgid();
+  struct passwd *pw = getpwuid(caller_uid);
+  char caller_name[256];
+  snprintf(caller_name ,sizeof caller_name ,"%s" ,pw ? pw->pw_name : "unknown");
+
+  clearenv();
+  setenv("PATH" ,"/usr/sbin:/usr/bin:/sbin:/bin" ,1);
+  setenv("LANG" ,"C.UTF-8" ,1);
+  umask(077);
+  prctl(PR_SET_DUMPABLE ,0 ,0 ,0 ,0);
+  if( chdir("/") != 0 ) die("cannot change directory to /");
+
+  char number[32];
+  snprintf(number ,sizeof number ,"%ld" ,(long)caller_uid); setenv("MIG_CALLER_UID" ,number ,1);
+  snprintf(number ,sizeof number ,"%ld" ,(long)caller_gid); setenv("MIG_CALLER_GID" ,number ,1);
+  setenv("MIG_CALLER_NAME" ,caller_name ,1);
+
+  /* python3 -E -s -B <interpreter> <arguments> */
+  char *argument[ARGUMENT_MAX + 6];
+  int k = 0;
+  argument[k++] = (char *)PYTHON;
+  argument[k++] = (char *)"-E";
+  argument[k++] = (char *)"-s";
+  argument[k++] = (char *)"-B";
+  argument[k++] = interpreter;
+  for(int i = 1; i < argc; ++i) argument[k++] = argv[i];
+  argument[k] = NULL;
+
+  execv(PYTHON ,argument);
+  die("cannot start %s: %s" ,PYTHON ,strerror(errno));
+}
index 42a71ae..e937760 100644 (file)
@@ -1,6 +1,6 @@
 # -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
 """
-FileLanguage - a small command language for writing files, Man-In-Grey's
+FileLanguage - a small command language for writing files, Man_In_Grey's
 test language. It is a constrained form of the POSIX file interface.
 
   open(directory ,name ,mode ,owner ,permission)   produces a symbol for the file
index 5d310d7..c5c45d9 100644 (file)
@@ -1,6 +1,6 @@
 # -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
 """
-Journal - the journal of Man-In-Grey, independent of any command language.
+Journal - the journal of Man_In_Grey, independent of any command language.
 
 A composer places commands into a journal. A command is data: the name of
 the command, its arguments, and the composer that placed it. A command whose
index b5063b9..2764b9d 100755 (executable)
@@ -3,26 +3,30 @@
 """
 Man_In_Grey
 
-Runs the composers on a stage, which place commands into one journal, and
-hands the journal to a gate, which starts a bespoke journal interpreter. The
-journal interpreter checks every command against its constraints, and only
-then executes the journal.
-
-A composer is a Python file on the stage defining compose(prov ,journal_iface).
-prov describes where the composer is; journal_iface places commands of the
-file command language: open, write and unlink.
-
-Usage: Man_In_Grey --stage <dir> [option ...]
-  help | -h | --help               print this message (also printed when given no arguments)
-  --stage <dir>                    the stage of composers
-  --input_acceptance <file>        a Python file defining accept(prov), true for each stage
-                                   file that is a composer; by default every file ending in
-                                   .py whose name does not begin with a dot
+Runs composers, which place commands into one journal, and hands the journal
+to a gate, which starts a bespoke journal interpreter. The journal interpreter
+checks every command against its constraints, and only then executes the
+journal.
+
+A composer is a Python file defining compose(prov ,journal_iface). prov
+describes where the composer is; journal_iface places commands of the file
+command language: open, write and unlink.
+
+Usage: Man_In_Grey <command> <path> [option ...]
+  help | -h | --help         print this message (also printed when given no arguments)
+  compose <file>             run the one composer in <file>
+  orchestrate <directory>    run every composer in <directory>, breadth first, then
+                             by name; the layout of the directory carries no meaning
+
+Options:
+  --input_acceptance <file>        orchestrate only: a Python file defining accept(prov),
+                                   true for each file that is a composer; by default every
+                                   file ending in .py whose name does not begin with a dot
   --gate <file>                    the gate to hand the journal to; by default
                                    ../Man_In_Grey_file_gate/Man_In_Grey_file_gate
   --constraints <file>             constraints for an unprivileged run, given to the
                                    journal interpreter
-  --phase-0-then-stop              stop after checking the stage and the filter
+  --phase-0-then-stop              stop after checking the path and the filter
   --phase-1-print                  print the journal after composing
   --phase-1-then-stop              stop after composing; nothing reaches the gate
   --phase-2-print                  print the journal as the journal interpreter decoded it
@@ -59,7 +63,8 @@ STOP_OPTION = {
   ,"--phase-2-filesystem-then-stop": "filesystem"
 }
 FLAG = {"--phase-0-then-stop" ,"--phase-1-print" ,"--phase-1-then-stop" ,"--phase-2-print"} | set(STOP_OPTION)
-VALUE = {"--stage" ,"--input_acceptance" ,"--gate" ,"--constraints"}
+VALUE = {"--input_acceptance" ,"--gate" ,"--constraints"}
+COMMAND = ("compose" ,"orchestrate")
 
 
 # --------------------------------------------------------------------------------
@@ -70,7 +75,7 @@ class Provenance:
   Where a composer is. Given to the acceptance filter and to compose().
   """
   __slots__ = (
-    "stage_root_dpath"
+    "composer_root_dpath"
     ,"composer_abs_fpath"
     ,"composer_rel_fpath"
     ,"composer_dir_dpath"
@@ -79,15 +84,14 @@ class Provenance:
     ,"cwd_dpath"
   )
 
-  def __init__(self ,stage_root ,composer_path):
-    self.stage_root_dpath = stage_root.resolve()
+  def __init__(self ,composer_root ,composer_path):
+    self.composer_root_dpath = composer_root.resolve()
     self.composer_abs_fpath = composer_path.resolve()
-    self.composer_rel_fpath = self.composer_abs_fpath.relative_to(self.stage_root_dpath)
+    self.composer_rel_fpath = self.composer_abs_fpath.relative_to(self.composer_root_dpath)
     self.composer_dir_dpath = self.composer_abs_fpath.parent
     name = self.composer_abs_fpath.name
-    for suffix in (".stage.py" ,".py"):
-      if name.endswith(suffix):
-        name = name[:-len(suffix)]; break
+    if name.endswith(".py"):
+      name = name[:-3]
     self.implied_fname = name
     self.process_user = getpass.getuser()
     self.cwd_dpath = Path.cwd().resolve()
@@ -109,22 +113,22 @@ def accept_function(filter_path):
     raise RuntimeError(f"{filter_path} defines no accept(prov)")
   return function
 
-def composer_list(stage_root ,accept):
-  """The accepted stage files, breadth first, then by name. Symbolic links to directories are not followed."""
+def composer_list(composer_root ,accept):
+  """The accepted files of the composer directory, breadth first, then by name. Symbolic links to directories are not followed."""
   found = []
-  for dirpath ,dirnames ,filenames in os.walk(stage_root):
+  for dirpath ,dirnames ,filenames in os.walk(composer_root):
     dirnames[:] = sorted(d for d in dirnames if not os.path.islink(os.path.join(dirpath ,d)))
     for name in filenames:
       path = Path(dirpath ,name)
       if not stat.S_ISREG(path.stat().st_mode):
         continue
-      prov = Provenance(stage_root ,path)
+      prov = Provenance(composer_root ,path)
       if accept(prov):
         found.append((len(prov.composer_rel_fpath.parts) ,prov.composer_rel_fpath.as_posix() ,prov))
   found.sort(key=lambda t: (t[0] ,t[1]))
   return [t[2] for t in found]
 
-def compose_all(stage_root ,prov_list):
+def compose_all(composer_root ,prov_list):
   """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())
@@ -148,28 +152,41 @@ def compose_all(stage_root ,prov_list):
     "composed_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ")
     ,"process_user": getpass.getuser()
     ,"host": os.uname().nodename
-    ,"stage_root": str(stage_root.resolve())
+    ,"composer_root": str(composer_root.resolve())
     ,"composer_list": [p.composer_rel_fpath.as_posix() for p in prov_list]
   }
   return journal
 
-def run(stage ,input_acceptance ,gate ,constraints ,flag ,stop_after):
-  stage_root = Path(stage)
-  if not stage_root.is_dir():
-    print(f"error: the stage {stage_root} is not a directory" ,file=sys.stderr)
-    return 2
+def run(command ,path ,input_acceptance ,gate ,constraints ,flag ,stop_after):
+  path = Path(path)
+  if command == "compose":
+    if input_acceptance:
+      print("error: --input_acceptance is for orchestrate; compose runs the one file it is given" ,file=sys.stderr)
+      return 2
+    if not path.is_file():
+      print(f"error: the composer {path} is not a file" ,file=sys.stderr)
+      return 2
+    composer_root = path.resolve().parent
+  else:
+    if not path.is_dir():
+      print(f"error: {path} is not a directory" ,file=sys.stderr)
+      return 2
+    composer_root = path
   try:
     accept = accept_function(Path(input_acceptance) if input_acceptance else None)
   except Exception as error:
     print(f"error: acceptance filter: {error}" ,file=sys.stderr)
     return 2
   if "--phase-0-then-stop" in flag:
-    print(f"phase 0: OK, stage {stage_root.resolve()}")
+    print(f"phase 0: OK, {command} {path.resolve()}")
     return 0
 
   try:
-    prov_list = composer_list(stage_root ,accept)
-    journal = compose_all(stage_root ,prov_list)
+    if command == "compose":
+      prov_list = [Provenance(composer_root ,path)]
+    else:
+      prov_list = composer_list(composer_root ,accept)
+    journal = compose_all(composer_root ,prov_list)
   except Exception as error:
     print(f"error: {error}" ,file=sys.stderr)
     return 2
@@ -202,26 +219,32 @@ 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 not in COMMAND:
+    print(f"Error: unknown command '{command}'." ,file=sys.stderr)
+    print_help()
+    return 2
+  if not rest or rest[0].startswith("--"):
+    print(f"Error: {command} is given a {'file' if command == 'compose' else 'directory'} first." ,file=sys.stderr)
+    return 2
+  path ,rest = rest[0] ,rest[1:]
   value ,flag = {} ,set()
   i = 0
-  while i < len(argv):
-    word = argv[i]
-    if word in VALUE and i + 1 < len(argv):
-      value[word] = argv[i + 1]; i += 2
+  while i < len(rest):
+    word = rest[i]
+    if word in VALUE and i + 1 < len(rest):
+      value[word] = rest[i + 1]; i += 2
     elif word in FLAG:
       flag.add(word); i += 1
     else:
       print(f"Error: unknown or incomplete option '{word}'." ,file=sys.stderr)
       return 2
-  if "--stage" not in value:
-    print("Error: --stage <dir> is required." ,file=sys.stderr)
-    return 2
   stop = [STOP_OPTION[f] for f in flag if f in STOP_OPTION]
   if len(stop) > 1:
     print("Error: give at most one --phase-2-...-then-stop option." ,file=sys.stderr)
     return 2
   return run(
-    value["--stage"] ,value.get("--input_acceptance") ,value.get("--gate") ,value.get("--constraints")
+    command ,path ,value.get("--input_acceptance") ,value.get("--gate") ,value.get("--constraints")
     ,flag ,stop[0] if stop else None
   )
 
index f01cadb..cdca012 100644 (file)
@@ -1,6 +1,6 @@
 # -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
 """
-journal_interpreter - the general journal interpreter of Man-In-Grey.
+journal_interpreter - the general journal interpreter of Man_In_Grey.
 
 A bespoke journal interpreter is a gate, a guard and this general
 interpreter. The bespoke interpreter chooses the command language and the
diff --git a/developer/authored/Man_In_Grey_file_gate.make/Man_In_Grey_file_gate.CLI.c b/developer/authored/Man_In_Grey_file_gate.make/Man_In_Grey_file_gate.CLI.c
deleted file mode 100644 (file)
index eeb0114..0000000
+++ /dev/null
@@ -1,127 +0,0 @@
-/*
-  Man_In_Grey_file_gate
-
-  The gate of the bespoke journal interpreter for the file command language.
-  Installed setuid root, it starts the journal interpreter with root's rights
-  while the real uid still names the caller. Unblessed, it starts the journal
-  interpreter with the caller's rights.
-
-  The journal interpreter is at a path fixed when the gate is compiled,
-  relative to the gate: ../Man_In_Grey/journal_interpreter_file.py. The caller
-  cannot name another. When running privileged, the gate refuses to start it
-  unless the interpreter, and every directory above it, is owned by root and
-  writable by no other account.
-
-  The journal arrives on standard input and is passed on unread. Every
-  argument is passed on to the journal interpreter, except --print-flags,
-  which reports the caller and whether the gate is running privileged.
-
-  Who is allowed to hand a journal to the interpreter is decided by the
-  interpreter's constraints, not here.
-*/
-
-#include <errno.h>
-#include <libgen.h>
-#include <limits.h>
-#include <pwd.h>
-#include <stdarg.h>
-#include <stdbool.h>
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include <sys/prctl.h>
-#include <sys/stat.h>
-#include <sys/types.h>
-#include <unistd.h>
-
-#define PYTHON "/usr/bin/python3"
-#define INTERPRETER_RELATIVE "../Man_In_Grey/journal_interpreter_file.py"
-#define ARGUMENT_MAX 64
-
-Local void die(const char *format ,...){
-  va_list ap; va_start(ap ,format);
-  fputs("Man_In_Grey_file_gate: " ,stderr);
-  vfprintf(stderr ,format ,ap); va_end(ap);
-  fputc('\n' ,stderr);
-  _exit(2);
-}
-
-Local bool privileged(void){
-  return geteuid() != getuid() || getegid() != getgid();
-}
-
-/* path and every directory above it: owned by root, writable by no other account */
-Local void check_owned_by_root(const char *path){
-  char prefix[PATH_MAX];
-  size_t length = strlen(path);
-  if(length >= sizeof prefix) die("path too long: %s" ,path);
-  for(size_t i = 1; i <= length; ++i){
-    if( i < length && path[i] != '/' ) continue;
-    memcpy(prefix ,path ,i); prefix[i] = '\0';
-    struct stat st;
-    if( lstat(prefix ,&st) != 0 ) die("cannot examine %s: %s" ,prefix ,strerror(errno));
-    if( st.st_uid != 0 ) die("refusing to run privileged: %s is not owned by root" ,prefix);
-    if( st.st_mode & (S_IWGRP | S_IWOTH) ) die("refusing to run privileged: %s is writable by an account other than root" ,prefix);
-  }
-  struct stat st;
-  if( lstat("/" ,&st) != 0 || st.st_uid != 0 ) die("refusing to run privileged: / is not owned by root");
-}
-
-Local void print_flags(void){
-  struct passwd *pw = getpwuid(getuid());
-  printf("caller.uid=%ld\n" ,(long)getuid());
-  printf("caller.name=%s\n" ,pw ? pw->pw_name : "unknown");
-  printf("effective.uid=%ld\n" ,(long)geteuid());
-  printf("privileged=%d\n" ,privileged() ? 1 : 0);
-}
-
-int main(int argc ,char **argv){
-  if( argc > 1 && strcmp(argv[1] ,"--print-flags") == 0 ){ print_flags(); return 0; }
-  if( argc > ARGUMENT_MAX ) die("too many arguments");
-
-  char exe[PATH_MAX];
-  ssize_t n = readlink("/proc/self/exe" ,exe ,sizeof exe - 1);
-  if( n < 0 ) die("cannot resolve /proc/self/exe: %s" ,strerror(errno));
-  exe[n] = '\0';
-
-  char candidate[PATH_MAX];
-  if( snprintf(candidate ,sizeof candidate ,"%s/%s" ,dirname(exe) ,INTERPRETER_RELATIVE) >= (int)sizeof candidate )
-    die("path too long");
-  char interpreter[PATH_MAX];
-  if( !realpath(candidate ,interpreter) ) die("journal interpreter not found: %s" ,candidate);
-
-  if( privileged() ) check_owned_by_root(interpreter);
-
-  /* harden the process before starting the interpreter */
-  uid_t caller_uid = getuid();
-  gid_t caller_gid = getgid();
-  struct passwd *pw = getpwuid(caller_uid);
-  char caller_name[256];
-  snprintf(caller_name ,sizeof caller_name ,"%s" ,pw ? pw->pw_name : "unknown");
-
-  clearenv();
-  setenv("PATH" ,"/usr/sbin:/usr/bin:/sbin:/bin" ,1);
-  setenv("LANG" ,"C.UTF-8" ,1);
-  umask(077);
-  prctl(PR_SET_DUMPABLE ,0 ,0 ,0 ,0);
-  if( chdir("/") != 0 ) die("cannot change directory to /");
-
-  char number[32];
-  snprintf(number ,sizeof number ,"%ld" ,(long)caller_uid); setenv("MIG_CALLER_UID" ,number ,1);
-  snprintf(number ,sizeof number ,"%ld" ,(long)caller_gid); setenv("MIG_CALLER_GID" ,number ,1);
-  setenv("MIG_CALLER_NAME" ,caller_name ,1);
-
-  /* python3 -E -s -B <interpreter> <arguments> */
-  char *argument[ARGUMENT_MAX + 6];
-  int k = 0;
-  argument[k++] = (char *)PYTHON;
-  argument[k++] = (char *)"-E";
-  argument[k++] = (char *)"-s";
-  argument[k++] = (char *)"-B";
-  argument[k++] = interpreter;
-  for(int i = 1; i < argc; ++i) argument[k++] = argv[i];
-  argument[k] = NULL;
-
-  execv(PYTHON ,argument);
-  die("cannot start %s: %s" ,PYTHON ,strerror(errno));
-}
index 4cdf48c..e787fb4 100644 (file)
@@ -95,7 +95,7 @@
       <RT·section id="sec-band">
         <RT·name>Out-of-band signalling</RT·name>
         <p>
-          A template mixes two kinds of thing in one stream: the content to be written, and marks that say where to substitute. That is <RT·term>in-band signalling</RT·term>. Some content will always look like a mark, so the template language needs an escape, and the escape is itself a mark. There is no tight way to design the aliasing out. Each level of nesting multiplies the escapes: an f-string that explains how to write f-strings escapes its braces, and a document about that f-string escapes them again.
+          The general method of filling in templates mixes data and control together, i.e. the content to be written, and marks that say where to substitute. Thus this method is a form of <RT·term>in-band signalling</RT·term>. With in-band signaling there will potentially that a content string will be identical to a control string, so data and control can alias. There is no tight way to design the aliasing out. The typical workaround is to disallow control strings in the data and to replace missing characters with 'escape sequences', where the escape then is control.  Each level of nesting multiplies the escapes: an f-string that explains how to write f-strings escapes its braces, and a document about that f-string escapes them again.
         </p>
         <p>
           Man_In_Grey signals out of band. A composer is a program, so the logic that forms the output is code, with loops, conditions and lookups, rather than marks embedded in the output. A command in the journal is a structure, and file content is carried in it as a byte string with its length. The journal interpreter never scans content for meaning, so content can hold anything, including text that looks like a journal, with no escaping. This is the same principle as an extent string, where the extent of the content travels beside it rather than being marked within it.
diff --git a/tester/authored/write_file/composer/DNS/unbound.conf.py b/tester/authored/write_file/composer/DNS/unbound.conf.py
new file mode 100644 (file)
index 0000000..cdbc8ef
--- /dev/null
@@ -0,0 +1,7 @@
+# replace: the second run discards the first run's file, keeping no backup.
+# The content is written in two parts.
+
+def compose(prov ,journal_iface):
+  file = journal_iface.open("out/net" ,prov.implied_fname ,"replace" ,prov.process_user ,"0444")
+  journal_iface.write(file ,"server:\n")
+  journal_iface.write(file ,"  verbosity: 1\n")
diff --git a/tester/authored/write_file/composer/obsolete.py b/tester/authored/write_file/composer/obsolete.py
new file mode 100644 (file)
index 0000000..a1a52bd
--- /dev/null
@@ -0,0 +1,4 @@
+# unlink: the first run removes out/obsolete.conf; the second finds nothing to remove.
+
+def compose(prov ,journal_iface):
+  journal_iface.unlink("out" ,"obsolete.conf")
diff --git a/tester/authored/write_file/composer/unbound_conf.py b/tester/authored/write_file/composer/unbound_conf.py
new file mode 100644 (file)
index 0000000..4f9a6d2
--- /dev/null
@@ -0,0 +1,6 @@
+# replace_keep: the second run keeps the first run's file as a timestamped backup.
+# The file name is the one this composer implies: its own name less .py.
+
+def compose(prov ,journal_iface):
+  file = journal_iface.open("out" ,prov.implied_fname ,"replace_keep" ,prov.process_user ,0o444)
+  journal_iface.write(file ,"server:\n  do-ip6: no\n")
diff --git a/tester/authored/write_file/composer/web/site_conf.py b/tester/authored/write_file/composer/web/site_conf.py
new file mode 100644 (file)
index 0000000..985cbb3
--- /dev/null
@@ -0,0 +1,5 @@
+# replace_keep, under a file name given here rather than implied.
+
+def compose(prov ,journal_iface):
+  file = journal_iface.open("out/web" ,"nginx.conf" ,"replace_keep" ,prov.process_user ,"0644")
+  journal_iface.write(file ,"events {}\nhttp { server { listen 8080; } }\n")
diff --git a/tester/authored/write_file/composer_argument/wrong_mode.py b/tester/authored/write_file/composer_argument/wrong_mode.py
new file mode 100644 (file)
index 0000000..42210a8
--- /dev/null
@@ -0,0 +1,4 @@
+# A wrong argument: the journal interface raises an error, and no journal reaches the gate.
+
+def compose(prov ,journal_iface):
+  journal_iface.open("out" ,"unbound_conf" ,"overwrite" ,prov.process_user ,0o444)
diff --git a/tester/authored/write_file/composer_create/new.py b/tester/authored/write_file/composer_create/new.py
new file mode 100644 (file)
index 0000000..198f0fc
--- /dev/null
@@ -0,0 +1,5 @@
+# create: allowed once; a second run is refused, as the file then exists.
+
+def compose(prov ,journal_iface):
+  file = journal_iface.open("out" ,"new.conf" ,"create" ,prov.process_user ,0o600)
+  journal_iface.write(file ,"made once\n")
diff --git a/tester/authored/write_file/composer_refused/allowed_and_not.py b/tester/authored/write_file/composer_refused/allowed_and_not.py
new file mode 100644 (file)
index 0000000..b0be432
--- /dev/null
@@ -0,0 +1,7 @@
+# One file the constraints allow, and one they do not. The whole journal is refused.
+
+def compose(prov ,journal_iface):
+  allowed = journal_iface.open("out" ,"unbound_conf" ,"replace_keep" ,prov.process_user ,0o444)
+  journal_iface.write(allowed ,"this is not to be written\n")
+  forbidden = journal_iface.open("out" ,"forbidden.conf" ,"replace" ,prov.process_user ,0o644)
+  journal_iface.write(forbidden ,"nor this\n")
index 5613e6f..3aee789 100755 (executable)
@@ -3,21 +3,21 @@
 """
 run
 
-The write_file test of Man-In-Grey's file command language. It runs the
+The write_file test of Man_In_Grey's file command language. It runs the
 promoted Man_In_Grey, from $REPO_HOME/consumer, through an unblessed gate,
 with constraints it writes for the files it expects, and checks:
 
-  run_1      the stage beside this file: each composer's file written, and
+  run_1      orchestrate composer/: each composer's file written, and
              out/obsolete.conf, made by the test beforehand, unlinked; the
              files are compared with expected/run_1
-  run_2      the same stage again: replace_keep files kept as backups named
+  run_2      orchestrate composer/ again: replace_keep files kept as backups named
              <file>.YYYYMMDDTHHMMSSZ, the replace file overwritten with no
              backup; compared with expected/run_2
-  create     stage_create: a file opened in create mode is written once, and
+  create     compose composer_create/new.py: a file opened in create mode is written once, and
              a second run is refused by the filesystem check
-  refused    stage_refused: a journal opening one allowed file and one the
+  refused    orchestrate composer_refused/: a journal opening one allowed file and one the
              constraints do not allow is refused whole by the constraint check
-  argument   stage_argument: a composer giving a wrong mode fails when it
+  argument   orchestrate composer_argument/: a composer giving a wrong mode fails when it
              places the command, and no journal reaches the gate
 
 A refused run is to leave every file as it was. Backup names carry the time
@@ -143,8 +143,8 @@ def compare(log ,run_name ,work_dpath):
       problem.append(f"owner uid {path.lstat().st_uid}, expected {os.getuid()}")
     log.check(not problem ,what ,"; ".join(problem) or None)
 
-def run_Man_In_Grey(program ,stage ,work_dpath ,constraint_fpath):
-  command = [str(program) ,"--stage" ,str(TEST_DPATH / stage) ,"--constraints" ,str(constraint_fpath)]
+def run_Man_In_Grey(program ,command ,path ,work_dpath ,constraint_fpath):
+  command = [str(program) ,command ,str(TEST_DPATH / path) ,"--constraints" ,str(constraint_fpath)]
   result = subprocess.run(command ,cwd=work_dpath ,capture_output=True ,text=True)
   return result.returncode ,(result.stdout + result.stderr).strip()
 
@@ -196,7 +196,7 @@ def test(repo_home):
 
   # run_1 and run_2
   for run_name in ("run_1" ,"run_2"):
-    status ,output = run_Man_In_Grey(program ,"stage" ,work_dpath ,constraint_fpath)
+    status ,output = run_Man_In_Grey(program ,"orchestrate" ,"composer" ,work_dpath ,constraint_fpath)
     if check_status(log ,f"{run_name} Man_In_Grey exits 0" ,status ,0 ,output):
       compare(log ,run_name ,work_dpath)
     else:
@@ -204,25 +204,25 @@ def test(repo_home):
       return log.summary()
 
   # create: once, then refused
-  status ,output = run_Man_In_Grey(program ,"stage_create" ,work_dpath ,constraint_fpath)
+  status ,output = run_Man_In_Grey(program ,"compose" ,"composer_create/new.py" ,work_dpath ,constraint_fpath)
   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 ,"stage_create" ,work_dpath ,constraint_fpath)
+  status ,output = run_Man_In_Grey(program ,"compose" ,"composer_create/new.py" ,work_dpath ,constraint_fpath)
   check_status(log ,"create again is refused by the filesystem check" ,status ,1 ,output ,"filesystem: refused")
   log.check(snapshot(out) == before ,"create again changes nothing")
 
   # a journal the constraints refuse
   before = snapshot(out)
-  status ,output = run_Man_In_Grey(program ,"stage_refused" ,work_dpath ,constraint_fpath)
+  status ,output = run_Man_In_Grey(program ,"orchestrate" ,"composer_refused" ,work_dpath ,constraint_fpath)
   check_status(log ,"refused is refused by the constraint check" ,status ,1 ,output ,"constraint: refused")
   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 ,"stage_argument" ,work_dpath ,constraint_fpath)
+  status ,output = run_Man_In_Grey(program ,"orchestrate" ,"composer_argument" ,work_dpath ,constraint_fpath)
   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")
 
diff --git a/tester/authored/write_file/stage/DNS/unbound.conf.py b/tester/authored/write_file/stage/DNS/unbound.conf.py
deleted file mode 100644 (file)
index cdbc8ef..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-# replace: the second run discards the first run's file, keeping no backup.
-# The content is written in two parts.
-
-def compose(prov ,journal_iface):
-  file = journal_iface.open("out/net" ,prov.implied_fname ,"replace" ,prov.process_user ,"0444")
-  journal_iface.write(file ,"server:\n")
-  journal_iface.write(file ,"  verbosity: 1\n")
diff --git a/tester/authored/write_file/stage/obsolete.py b/tester/authored/write_file/stage/obsolete.py
deleted file mode 100644 (file)
index a1a52bd..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-# unlink: the first run removes out/obsolete.conf; the second finds nothing to remove.
-
-def compose(prov ,journal_iface):
-  journal_iface.unlink("out" ,"obsolete.conf")
diff --git a/tester/authored/write_file/stage/unbound_conf.py b/tester/authored/write_file/stage/unbound_conf.py
deleted file mode 100644 (file)
index 4f9a6d2..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-# replace_keep: the second run keeps the first run's file as a timestamped backup.
-# The file name is the one this composer implies: its own name less .py.
-
-def compose(prov ,journal_iface):
-  file = journal_iface.open("out" ,prov.implied_fname ,"replace_keep" ,prov.process_user ,0o444)
-  journal_iface.write(file ,"server:\n  do-ip6: no\n")
diff --git a/tester/authored/write_file/stage/web/site_conf.py b/tester/authored/write_file/stage/web/site_conf.py
deleted file mode 100644 (file)
index 985cbb3..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-# replace_keep, under a file name given here rather than implied.
-
-def compose(prov ,journal_iface):
-  file = journal_iface.open("out/web" ,"nginx.conf" ,"replace_keep" ,prov.process_user ,"0644")
-  journal_iface.write(file ,"events {}\nhttp { server { listen 8080; } }\n")
diff --git a/tester/authored/write_file/stage_argument/wrong_mode.py b/tester/authored/write_file/stage_argument/wrong_mode.py
deleted file mode 100644 (file)
index 42210a8..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-# A wrong argument: the journal interface raises an error, and no journal reaches the gate.
-
-def compose(prov ,journal_iface):
-  journal_iface.open("out" ,"unbound_conf" ,"overwrite" ,prov.process_user ,0o444)
diff --git a/tester/authored/write_file/stage_create/new.py b/tester/authored/write_file/stage_create/new.py
deleted file mode 100644 (file)
index 198f0fc..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-# create: allowed once; a second run is refused, as the file then exists.
-
-def compose(prov ,journal_iface):
-  file = journal_iface.open("out" ,"new.conf" ,"create" ,prov.process_user ,0o600)
-  journal_iface.write(file ,"made once\n")
diff --git a/tester/authored/write_file/stage_refused/allowed_and_not.py b/tester/authored/write_file/stage_refused/allowed_and_not.py
deleted file mode 100644 (file)
index b0be432..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-# One file the constraints allow, and one they do not. The whole journal is refused.
-
-def compose(prov ,journal_iface):
-  allowed = journal_iface.open("out" ,"unbound_conf" ,"replace_keep" ,prov.process_user ,0o444)
-  journal_iface.write(allowed ,"this is not to be written\n")
-  forbidden = journal_iface.open("out" ,"forbidden.conf" ,"replace" ,prov.process_user ,0o644)
-  journal_iface.write(forbidden ,"nor this\n")