package catalogue import ( "fmt" "regexp" "sort" "strings" ) // The account's environment and the login shell's code, composed from the modules a node runs // (novox/hq ADR 0203, ADR 0204). // // **The same shape as the jails.** Every module may contribute — a toolchain its directory on PATH, // a version manager a variable naming its home, a prompt the code that loads it — naming no node, no // path and no file of the shell's (ADR 0112). The one module holding the matching seat places the // result with a placeholder in its own file, and the controller fills it from every module on the // node. A node not running a module has none of its contribution, and unassigning one takes its // lines away at the next composition. // // **Two kinds of contribution, kept apart on purpose.** The environment is facts, which the // controller writes in two standard formats — POSIX assignment and the service manager's // environment.d — so a terminal, a script, the login shell's `execute` and a graphical session all // read the same values (ADR 0203). Shell code is not a fact: it is text in one shell's syntax, which // the controller sorts into a slot and pastes without reading, as it pastes a jail's stanza (ADR // 0204). // EnvironmentSeat and LoginShellSeat are the seats whose holders may place what the modules // contributed: the account's environment, and the login shell's code. const ( EnvironmentSeat = "node-environment" LoginShellSeat = "node-login-shell" ) // Where an environment entry on PATH goes: before the account's existing PATH, or after it. const ( PathAtStart = "start" PathAtEnd = "end" ) // Environment is what one module adds to the account's environment (novox/hq ADR 0203). type Environment struct { // Variables are names and literal values. A value may name the machine's own facts with // ${machine:…}, resolved before anything is written, and nothing else that expands. Variables map[string]string `json:"variables,omitempty"` // Path is entries on the account's PATH, each at its start or its end, in the order declared. Path []PathEntry `json:"path,omitempty"` } // PathEntry is one directory a module puts on the account's PATH. type PathEntry struct { Entry string `json:"entry"` At string `json:"at"` } // ShellCode is one piece of code a module adds to a shell's startup (novox/hq ADR 0204). type ShellCode struct { // For is the shell the code is written in. For string `json:"for"` // Slot is where it runs among the other modules' code: first, normal or last. Named rather // than numbered, because every contributor would guess a number and a collision says nothing. Slot string `json:"slot"` // Code is never interpreted — it is the shell's syntax, and only the shell reads it. Code string `json:"code"` } // The shells and slots a contribution may name (novox/hq ADR 0204). Closed, so a typo is a refusal // at the check rather than code that silently lands in no placeholder. var ( knownShells = []string{"zsh", "bash", "fish"} knownSlots = []string{"first", "normal", "last"} ) // The two renderings of the environment a holder may place (novox/hq ADR 0203, decision 3). const ( EnvironmentPOSIX = "posix" EnvironmentSystemd = "systemd" ) // ofEnvironment and ofShell are where a holder places what was contributed: ${environment:posix}, // ${environment:systemd} and ${shell::}. Loose inside the braces on purpose, so a // misspelt key is found and refused rather than left in a file as a literal nobody reads. var ( ofEnvironment = regexp.MustCompile(`\$\{environment:([^}]*)\}`) ofShell = regexp.MustCompile(`\$\{shell:([^}]*)\}`) ) // variableName is a POSIX shell variable name, which is also what environment.d accepts. var variableName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) // environmentProblems is what is wrong with this module's environment contribution, from the // manifest alone. func (m Manifest) environmentProblems() []string { if m.Environment == nil { return nil } var problems []string for _, n := range sortedKeys(m.Environment.Variables) { switch { case !variableName.MatchString(n): problems = append(problems, fmt.Sprintf( "%s sets the variable %q, which is not a name a shell accepts: a letter or an "+ "underscore, then letters, digits and underscores", m.Module, n)) continue case n == "PATH": // PATH is the one variable every module shares, so no module may set it whole: a second // setter would replace the first's entries, and the account's own PATH with them. problems = append(problems, fmt.Sprintf( "%s sets PATH as a variable; a module adds an entry under environment.path, at the "+ "start or the end, and PATH is composed from every module's (novox/hq ADR 0203)", m.Module)) continue } if why := literalProblem(m.Environment.Variables[n]); why != "" { problems = append(problems, fmt.Sprintf( "%s sets %s to %q, which %s — %s", m.Module, n, m.Environment.Variables[n], why, literalRule)) } } seen := map[string]bool{} for i, p := range m.Environment.Path { switch { case p.Entry == "": problems = append(problems, fmt.Sprintf("%s's PATH entry %d names no directory", m.Module, i+1)) case strings.Contains(ofMachine.ReplaceAllString(p.Entry, ""), ":"): // A colon is PATH's own separator, so an entry holding one is two entries, and the // check that it is already present would look for the wrong thing. problems = append(problems, fmt.Sprintf( "%s puts %q on PATH, which holds a colon, PATH's own separator", m.Module, p.Entry)) case seen[p.Entry]: problems = append(problems, fmt.Sprintf("%s puts %q on PATH twice", m.Module, p.Entry)) default: if why := literalProblem(p.Entry); why != "" { problems = append(problems, fmt.Sprintf( "%s puts %q on PATH, which %s — %s", m.Module, p.Entry, why, literalRule)) } } seen[p.Entry] = true if p.At != PathAtStart && p.At != PathAtEnd { problems = append(problems, fmt.Sprintf( "%s puts %q on PATH at %q; an entry goes at %q or %q of the account's PATH", m.Module, p.Entry, p.At, PathAtStart, PathAtEnd)) } } return problems } // literalRule is why a value must be literal, said with every refusal of one. const literalRule = "a value is literal, so a POSIX shell and the service manager read it alike, and " + "names the machine only through the mesh's own ${machine:…} facts (novox/hq ADR 0203)" // literalProblem is why a value cannot be written, unquoted by either reader, as the same string in // both formats — or nothing. A `$` would expand differently in each; a quote or a backslash is // quoting in one and a character in the other; a line break ends the line in both. func literalProblem(v string) string { switch { case strings.ContainsAny(v, `'"`): return "holds a quote" case strings.Contains(v, `\`): return "holds a backslash" case strings.ContainsAny(v, "\n\r"): return "holds a line break" case strings.ContainsRune(v, 0): return "holds a NUL" case strings.Contains(ofMachine.ReplaceAllString(v, ""), "$"): return "holds a $ that is not one of the machine's ${machine:…} facts" } return "" } // shellProblems is what is wrong with this module's shell code, from the manifest alone. The code // itself is not judged: it is the shell's syntax, which the controller does not read. func (m Manifest) shellProblems() []string { var problems []string for i, c := range m.Shell { if !oneOf(knownShells, c.For) { problems = append(problems, fmt.Sprintf( "%s's shell code %d is for %q; the shells are %s", m.Module, i+1, c.For, strings.Join(knownShells, ", "))) } if !oneOf(knownSlots, c.Slot) { problems = append(problems, fmt.Sprintf( "%s's shell code %d goes in the slot %q; the slots are %s", m.Module, i+1, c.Slot, strings.Join(knownSlots, ", "))) } if strings.TrimSpace(c.Code) == "" { problems = append(problems, fmt.Sprintf("%s's shell code %d has no code", m.Module, i+1)) } } return problems } // contributionPlaceholderProblems is every place this module's resources name the environment or // the shell's code and may not — judged from the manifest, so the catalogue check refuses it before // a mesh does, and again at composition in the same words. func (m Manifest) contributionPlaceholderProblems() []string { var problems []string for _, r := range m.Resources { problems = append(problems, placeholderProblems(m, r)...) } return problems } // placeholderProblems is what is wrong with one resource's ${environment:…} and ${shell:…}. // // **The seat authorises it, not the placeholder** (novox/hq ADR 0203 §5, ADR 0204 §3), as the seat // authorises the bus's user list: a module that does not hold the account's environment writing it // would be a second writer of a file there is one of, and a module that does not hold the login // shell writing every module's shell code would be a second shell. func placeholderProblems(m Manifest, r map[string]any) []string { var problems []string for _, field := range sortedKeys(r) { s, ok := r[field].(string) if !ok { continue } env := ofEnvironment.FindAllStringSubmatch(s, -1) code := ofShell.FindAllStringSubmatch(s, -1) if len(env)+len(code) == 0 { continue } if field != "content" { // Placed only where a file's bytes are, which is where every one of them is meant to go: // a path or an owner holding several lines of shell is nothing the host could act on. problems = append(problems, fmt.Sprintf( "%s's resource %v names %s in its %s; the environment and the shell's code are placed "+ "only in a file's content", m.Module, r["id"], placeholderOf(env, code), field)) continue } for _, e := range env { if e[1] != EnvironmentPOSIX && e[1] != EnvironmentSystemd { problems = append(problems, fmt.Sprintf( "%s's resource %v names %s; the environment is ${environment:%s} or ${environment:%s}", m.Module, r["id"], e[0], EnvironmentPOSIX, EnvironmentSystemd)) } } if len(env) > 0 && !m.ClaimsSeat(EnvironmentSeat) { problems = append(problems, fmt.Sprintf( "%s's resource %v names %s and %s does not claim %s; the account's environment is "+ "written by that seat's holder alone (novox/hq ADR 0203)", m.Module, r["id"], env[0][0], m.Module, EnvironmentSeat)) } for _, c := range code { shell, slot, two := strings.Cut(c[1], ":") if !two || !oneOf(knownShells, shell) || !oneOf(knownSlots, slot) { problems = append(problems, fmt.Sprintf( "%s's resource %v names %s; shell code is ${shell::}, the shell one of "+ "%s and the slot one of %s", m.Module, r["id"], c[0], strings.Join(knownShells, ", "), strings.Join(knownSlots, ", "))) } } if len(code) > 0 && !m.ClaimsSeat(LoginShellSeat) { problems = append(problems, fmt.Sprintf( "%s's resource %v names %s and %s does not claim %s; every module's shell code is "+ "placed by the login shell's holder alone (novox/hq ADR 0204)", m.Module, r["id"], code[0][0], m.Module, LoginShellSeat)) } } return problems } func placeholderOf(env, code [][]string) string { if len(env) > 0 { return env[0][0] } return code[0][0] } // contributedEnvironment is one node's environment, gathered and in the order it is written. type contributedEnvironment struct { // variables is by module in name order, each module's sorted by name. variables []setBy // start and end are PATH's entries in their final order, each once. start, end []placedOn } type setBy struct { module string names []string values map[string]string } type placedOn struct { module, entry string } // inModuleOrder is the modules sorted by name — the order contributions are written in (novox/hq // ADR 0203, ADR 0204), so the same set composes byte for byte whatever order they were assigned in. func inModuleOrder(modules []Manifest) []Manifest { out := append([]Manifest(nil), modules...) sort.SliceStable(out, func(a, b int) bool { return out[a].Module < out[b].Module }) return out } // variablesSetOnce refuses a variable two modules on one node both set (novox/hq ADR 0203 §5), // naming both. Neither is chosen: whichever was written last would win in one reader and not // necessarily in the other, and the module that lost would not be told. func variablesSetOnce(modules []Manifest) error { setter := map[string]string{} for _, m := range inModuleOrder(modules) { if m.Environment == nil { continue } for _, n := range sortedKeys(m.Environment.Variables) { if first, taken := setter[n]; taken { return fmt.Errorf( "%s and %s both set %s on this machine; the account has one environment, so one "+ "of them must stop setting it (novox/hq ADR 0203)", first, m.Module, n) } setter[n] = m.Module } } return nil } // environmentOn gathers every module's environment on a node, with the machine's facts in place. // // A PATH entry two modules both add is written once, where the first puts it: two toolchains // sharing ~/.local/bin is ordinary, and nothing about it is in conflict. func environmentOn(modules []Manifest, facts map[string]string) (contributedEnvironment, error) { var env contributedEnvironment if err := variablesSetOnce(modules); err != nil { return env, err } placed := map[string]bool{} for _, m := range inModuleOrder(modules) { if m.Environment == nil { continue } if len(m.Environment.Variables) > 0 { set := setBy{module: m.Module, values: map[string]string{}} for _, n := range sortedKeys(m.Environment.Variables) { v, err := factsIn(m.Environment.Variables[n], facts, m.Module, n) if err != nil { return env, err } set.names = append(set.names, n) set.values[n] = v } env.variables = append(env.variables, set) } for _, p := range m.Environment.Path { entry, err := factsIn(p.Entry, facts, m.Module, "a PATH entry") if err != nil { return env, err } if strings.Contains(entry, ":") { return env, fmt.Errorf("%s puts %q on PATH on this machine, which holds a colon, PATH's own separator", m.Module, entry) } if placed[entry] { continue } placed[entry] = true if p.At == PathAtEnd { env.end = append(env.end, placedOn{m.Module, entry}) } else { env.start = append(env.start, placedOn{m.Module, entry}) } } } return env, nil } // factsIn resolves a contributed value's ${machine:…} facts with this machine's — first, before // either format is written, so both say the same thing (novox/hq ADR 0203). func factsIn(v string, facts map[string]string, module, what string) (string, error) { for _, key := range machineUsed(v) { value, has := facts[key] if !has { return "", fmt.Errorf("%s sets %s to a value that says ${machine:%s}, and this machine says %s", module, what, key, orNothing(namesOfFacts(facts))) } v = strings.ReplaceAll(v, fmt.Sprintf("${machine:%s}", key), value) } // Judged again once filled: a fact is the mesh's, and still has to be a literal both readers // take alike. if why := literalProblem(v); why != "" { return "", fmt.Errorf("%s sets %s to %q on this machine, which %s — %s", module, what, v, why, literalRule) } return v, nil } // posix is the environment as lines a POSIX shell sources (novox/hq ADR 0203 §3): every variable // exported, every PATH entry added only when it is missing, so sourcing the file twice — a login // shell that starts another — changes nothing. POSIX sh only, because sh, bash and zsh all read it. // // The start entries are written last-first: each is put in front of PATH, so the last written ends // up first, and the result reads in module order, then the order each module declared. func (e contributedEnvironment) posix() string { var b strings.Builder for _, set := range e.variables { fmt.Fprintf(&b, "# %s\n", set.module) for _, n := range set.names { fmt.Fprintf(&b, "export %s='%s'\n", n, set.values[n]) } } named := "" for i := len(e.start) - 1; i >= 0; i-- { p := e.start[i] if p.module != named { fmt.Fprintf(&b, "# %s\n", p.module) named = p.module } fmt.Fprintf(&b, "case \":${PATH}:\" in *':%s:'*) ;; *) PATH='%s'\"${PATH:+:${PATH}}\" ;; esac\n", p.entry, p.entry) } named = "" for _, p := range e.end { if p.module != named { fmt.Fprintf(&b, "# %s\n", p.module) named = p.module } fmt.Fprintf(&b, "case \":${PATH}:\" in *':%s:'*) ;; *) PATH=\"${PATH:+${PATH}:}\"'%s' ;; esac\n", p.entry, p.entry) } if len(e.start)+len(e.end) > 0 { b.WriteString("export PATH\n") } return b.String() } // systemd is the same environment as the service manager's environment.d reads it (novox/hq ADR // 0203 §3), for the account's user manager and so for everything a graphical session starts. Read // once per manager start, so it needs no guard against running twice; the account's existing PATH // sits between the start and the end entries. func (e contributedEnvironment) systemd() string { var b strings.Builder for _, set := range e.variables { fmt.Fprintf(&b, "# %s\n", set.module) for _, n := range set.names { fmt.Fprintf(&b, "%s=%s\n", n, set.values[n]) } } if len(e.start) > 0 { fmt.Fprintf(&b, "# %s\nPATH=%s${PATH:+:$PATH}\n", modulesOf(e.start), entriesOf(e.start)) } if len(e.end) > 0 { fmt.Fprintf(&b, "# %s\nPATH=${PATH:+$PATH:}%s\n", modulesOf(e.end), entriesOf(e.end)) } return b.String() } // modulesOf names who contributed a line holding several modules' entries, in the order they appear. func modulesOf(entries []placedOn) string { var names []string seen := map[string]bool{} for _, p := range entries { if !seen[p.module] { seen[p.module] = true names = append(names, p.module) } } return strings.Join(names, ", ") } func entriesOf(entries []placedOn) string { out := make([]string, len(entries)) for i, p := range entries { out[i] = p.entry } return strings.Join(out, ":") } // shellCode is every module's code for one shell and one slot (novox/hq ADR 0204 §3): in module // order, each module's pieces in the order it declared them, each preceded by a line naming the // module, and empty when nothing is contributed. func shellCode(modules []Manifest, shell, slot string) string { var b strings.Builder for _, m := range inModuleOrder(modules) { named := false for _, c := range m.Shell { if c.For != shell || c.Slot != slot { continue } if !named { fmt.Fprintf(&b, "# %s\n", m.Module) named = true } b.WriteString(c.Code) if !strings.HasSuffix(c.Code, "\n") { b.WriteString("\n") } } } return b.String() } // contributionsInto fills a holder's file with the node's environment and its shell code. // // **Last, after every other placeholder pass, and in one pass each.** Shell code is contributed text // in a shell's own syntax — `${XDG_CACHE_HOME:-$HOME/.cache}`, `${(%):-%n}` — and the rendered // environment holds `${PATH:+…}`: a scanner for the mesh's own placeholders that ran after these // were in place would read the shell's expansions as the mesh's and refuse them, or fill a // `${machine:…}` some module wrote for its shell to see. So nothing runs after them, the environment // is filled before the shell's code is, and each is replaced in a single pass over what the holder // wrote, so a contributed piece is never scanned again. func contributionsInto(resource map[string]any, m Manifest, modules []Manifest, facts map[string]string) error { if problems := placeholderProblems(m, resource); len(problems) > 0 { return fmt.Errorf("%s", problems[0]) } content, ok := resource["content"].(string) if !ok { return nil } if ofEnvironment.MatchString(content) { env, err := environmentOn(modules, facts) if err != nil { return err } content = ofEnvironment.ReplaceAllStringFunc(content, func(placeholder string) string { if ofEnvironment.FindStringSubmatch(placeholder)[1] == EnvironmentSystemd { return env.systemd() } return env.posix() }) } if ofShell.MatchString(content) { content = ofShell.ReplaceAllStringFunc(content, func(placeholder string) string { shell, slot, _ := strings.Cut(ofShell.FindStringSubmatch(placeholder)[1], ":") return shellCode(modules, shell, slot) }) } resource["content"] = content return nil }