package catalogue import ( "encoding/json" "errors" "fmt" "io/fs" "os" "path" "path/filepath" "sort" "strings" "unicode/utf8" ) // A file's text kept as a template file in its module's repository (novox/hq ADR 0349 §2, design 57 package T1, // issue 654). // // **Two forms, one composition.** A file resource says its text inline (`content`) or names a template file // beside its manifest (`"template": "files/"`), never both. The module's build packs `files/` into an // archive artifact of its own — an `archive` artifact built `from: "files"` — and resolving the build reads each // template's text back from that packed archive into `content` and drops `template`. So the manifest the mesh // holds, composition and the node-engine are exactly as they were: a module with its text in `files/` composes // byte-identically to the same module with it inline. Where there is no build — `module check`, the merge gate, // `module add` — the text is read from the repository tree beside the manifest (WithTemplatesFrom). // // **Read back by the builder, carried in the resolved manifest.** The builder reads each template from the very // bytes it hashed into the archive's digest, so the text the mesh holds is the archive's. The controller // registers the resolved manifest as the build result carries it and fetches nothing from the artifact store; a // manifest that still names a template when it would be held — built by a builder that predates templates — is // refused at registration (HeldTemplateProblem). // TemplateField is the key a file resource names its template file by. const TemplateField = "template" // TemplatesDir is the module's directory its template files are kept in, and the directory its templates archive // is built from. const TemplatesDir = "files" // Templated is whether any of the manifest's resources names a template. func (m Manifest) Templated() bool { for _, r := range m.Resources { if _, has := r[TemplateField]; has { return true } } return false } // TemplatesArtifact is the name of the archive artifact the module packs its templates into — the one built from // files/ — or "" when it declares none, or more than one (both refused by templateProblems for a module naming a // template). func TemplatesArtifact(m Manifest) string { found := templatesArtifacts(m) if len(found) != 1 { return "" } return found[0] } func templatesArtifacts(m Manifest) []string { if m.Build == nil { return nil } var found []string for _, a := range m.Build.Artifacts { if a.Kind == ArtifactArchive && a.URL == "" && cleanFrom(a.From) == TemplatesDir { found = append(found, a.Name) } } return found } // cleanFrom is an artifact's directory as one relative path: "./files/" is "files". func cleanFrom(from string) string { c := path.Clean(strings.TrimSpace(from)) return strings.TrimPrefix(c, "./") } // templateOf is the template a resource names, whether it names one, and what is wrong with it ("" when nothing). func templateOf(module string, r map[string]any) (string, bool, string) { raw, has := r[TemplateField] if !has { return "", false, "" } id := fmt.Sprint(r["id"]) if t := fmt.Sprint(r["type"]); t != "file" { return "", true, fmt.Sprintf("%s: %s is a %s and names a template; only a file takes its text from one "+ "(novox/hq ADR 0349 §2)", module, id, t) } name, ok := raw.(string) if !ok || strings.TrimSpace(name) == "" { return "", true, fmt.Sprintf("%s: %s names a template that is not a path: %v; a template is %q", module, id, raw, TemplatesDir+"/") } if _, both := r["content"]; both { return name, true, fmt.Sprintf("%s: %s names the template %q and says its content too; a file's text is "+ "in one place, the template or the content, never both (novox/hq ADR 0349 §2)", module, id, name) } if strings.HasPrefix(name, "/") || path.Clean(name) != name || !strings.HasPrefix(name, TemplatesDir+"/") || strings.Contains(name, "\\") { return name, true, fmt.Sprintf("%s: %s names the template %q, which is not a file under %s/ of its own "+ "repository: a template is %q, a plain relative path, and the build packs only %s/", module, id, name, TemplatesDir, TemplatesDir+"/", TemplatesDir) } return name, true, "" } // templateProblems is what is wrong with the manifest's templates, as written: a template on something not a file, // beside content, outside files/, or a module naming templates whose build packs no archive from files/. Whether // each template exists is judged where a tree is (ReadTemplates) or the archive's text is (Resolve). func templateProblems(m Manifest) []string { var problems []string templated := false for _, r := range m.Resources { _, has, wrong := templateOf(m.Module, r) templated = templated || has if wrong != "" { problems = append(problems, wrong) } } if !templated { return problems } switch found := templatesArtifacts(m); len(found) { case 0: problems = append(problems, fmt.Sprintf("%s names templates and its build packs no archive from %s/: "+ "declare an artifact {\"name\": \"files\", \"kind\": \"archive\", \"from\": %q} (novox/hq ADR 0349 §2)", m.Module, TemplatesDir, TemplatesDir)) case 1: default: sort.Strings(found) problems = append(problems, fmt.Sprintf("%s packs %s/ into %s; its templates are read from one archive", m.Module, TemplatesDir, strings.Join(found, " and "))) } return problems } // fillTemplates is resources with each template's text as its content and the template key dropped: text says the // text of a template by its path ("files/"), and whether it has one. A template it has no text for, or text // that is not UTF-8, is refused by name. Every resource is copied; the manifest's own are left as they were. func fillTemplates(module string, resources []map[string]any, text func(string) (string, bool), where string) ([]map[string]any, error) { out := make([]map[string]any, 0, len(resources)) var problems []string for _, r := range resources { name, has, wrong := templateOf(module, r) if !has { out = append(out, r) continue } if wrong != "" { problems = append(problems, wrong) continue } body, ok := text(name) if !ok { problems = append(problems, fmt.Sprintf("%s: %v names the template %q, and %s holds no such file", module, r["id"], name, where)) continue } if !utf8.ValidString(body) { // A manifest is JSON: text that is not UTF-8 would be changed on the way, silently. problems = append(problems, fmt.Sprintf("%s: %v names the template %q, which is not UTF-8 text; a "+ "file's text is carried as JSON and would not reach the machine as written", module, r["id"], name)) continue } filled := make(map[string]any, len(r)) for k, v := range r { filled[k] = v } delete(filled, TemplateField) filled["content"] = body out = append(out, filled) } if len(problems) > 0 { return nil, errors.New(strings.Join(problems, "; ")) } return out, nil } // ReadTemplates is the text of each template the manifest names, read from the module's directory in a repository // tree, by template path. Refused by name: a template the directory lacks, and anything under files/ the build's // packing refuses — files/ itself, or any directory or file under it, that is a symlink or not a plain file — so // nothing judged here can be read from outside the tree, and no tree passes here that the build would refuse. Each // template is read through an os.Root of the module's directory besides, which no path element can leave. func ReadTemplates(m Manifest, dir string) (map[string]string, error) { problems := templatesTreeProblems(m.Module, dir) root, err := os.OpenRoot(dir) if err != nil { return nil, fmt.Errorf("%s: the module's directory cannot be opened to read its templates: %w", m.Module, err) } defer root.Close() texts := map[string]string{} for _, r := range m.Resources { name, has, wrong := templateOf(m.Module, r) if !has { continue } if wrong != "" { problems = append(problems, wrong) continue } if _, read := texts[name]; read { continue } info, err := root.Lstat(name) switch { case errors.Is(err, fs.ErrNotExist): problems = append(problems, fmt.Sprintf("%s: %v names the template %q, and the module's directory "+ "has no such file", m.Module, r["id"], name)) continue case err != nil: problems = append(problems, fmt.Sprintf("%s: %v names the template %q, which cannot be read: %v", m.Module, r["id"], name, err)) continue case !info.Mode().IsRegular(): problems = append(problems, fmt.Sprintf("%s: %v names the template %q, which is not a plain file; "+ "the build packs only files", m.Module, r["id"], name)) continue } body, err := root.ReadFile(name) if err != nil { problems = append(problems, fmt.Sprintf("%s: %v names the template %q, which cannot be read: %v", m.Module, r["id"], name, err)) continue } texts[name] = string(body) } if len(problems) > 0 { return nil, errors.New(strings.Join(problems, "; ")) } return texts, nil } // templatesTreeProblems is everything under the module's files/ that the build's packing refuses: files/ itself not // a plain directory, and any entry under it that is neither a directory nor a plain file — a symlink, to a file or a // directory, a device, a pipe. Each said by its path. A files/ that does not exist is no problem here: each template // it would have held is refused as missing. func templatesTreeProblems(module, dir string) []string { top := filepath.Join(dir, TemplatesDir) info, err := os.Lstat(top) if errors.Is(err, fs.ErrNotExist) { return nil } if err != nil { return []string{fmt.Sprintf("%s: %s/ cannot be read: %v", module, TemplatesDir, err)} } if !info.IsDir() { return []string{fmt.Sprintf("%s: %s/ is %s, not a directory of the module's own; its templates are read "+ "only from inside the repository, and the build packs only directories and files", module, TemplatesDir, kindOf(info.Mode()))} } var problems []string err = filepath.WalkDir(top, func(at string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() || d.Type().IsRegular() { return nil } rel, _ := filepath.Rel(dir, at) problems = append(problems, fmt.Sprintf("%s: %s is %s; the build packs only directories and files, so "+ "%s/ holds nothing else", module, filepath.ToSlash(rel), kindOf(d.Type()), TemplatesDir)) return nil }) if err != nil { problems = append(problems, fmt.Sprintf("%s: %s/ cannot be read: %v", module, TemplatesDir, err)) } return problems } // kindOf says what a file that is not a plain file is. func kindOf(mode fs.FileMode) string { switch { case mode&fs.ModeSymlink != 0: return "a symlink" case mode&fs.ModeDevice != 0: return "a device" case mode&fs.ModeNamedPipe != 0: return "a named pipe" case mode&fs.ModeSocket != 0: return "a socket" case mode.IsDir(): return "a directory" } return "not a plain file" } // WithTemplatesFrom is the manifest with each template's text read from the module's directory in a repository // tree as its content: what `module check`, the merge gate and `module add` judge, where nothing was built. Read // again strictly afterwards, so every rule about a file's text judges the template's text exactly as it judges // inline content. A manifest naming no template is returned as it is. func WithTemplatesFrom(m Manifest, dir string) (Manifest, error) { if !m.Templated() { return m, nil } texts, err := ReadTemplates(m, dir) if err != nil { return Manifest{}, err } filled, err := fillTemplates(m.Module, m.Resources, func(name string) (string, bool) { t, ok := texts[name] return t, ok }, "the module's directory") if err != nil { return Manifest{}, err } out := m out.Resources = filled raw, err := json.Marshal(out) if err != nil { return Manifest{}, err } return ParseManifest(raw) } // HeldTemplateProblem is why a manifest cannot be held by the mesh because it still names a template, or "": the // manifest the mesh holds carries every file's text, which a build reads back from its templates archive. One // naming a template was resolved by a builder that does not read templates, and would compose files with no text. func HeldTemplateProblem(m Manifest) string { var named []string for _, r := range m.Resources { if t, has := r[TemplateField]; has { named = append(named, fmt.Sprintf("%v (%v)", r["id"], t)) } } if len(named) == 0 { return "" } return fmt.Sprintf("%s still names the template(s) of %s: the manifest the mesh holds carries each file's "+ "text, read back from the build's templates archive — this one was resolved by a builder that does not "+ "read templates, or not built at all (novox/hq ADR 0349 §2)", m.Module, strings.Join(named, ", ")) }