Let a file resource name a template file in its module's repository: composition, check and gate tests
mesh/merge-gate head f45e0a1de508 merged into trunk bfc79c64d989 — pass: the change touches no module of the mesh's graph
mesh/repo-check head f45e0a1de508 merged into trunk bfc79c64d989 — pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate head f45e0a1de508 merged into trunk bfc79c64d989 — pass: the change touches no module of the mesh's graph
mesh/repo-check head f45e0a1de508 merged into trunk bfc79c64d989 — pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A file resource may name a template file in its module's repository (novox/hq ADR 0349 §2, design 57 package
|
||||
// T1, issue 654): the build packs files/ into an archive, and resolving the build reads each template's text back
|
||||
// into content, so the mesh holds, composes and sends exactly what it would for the same text inline.
|
||||
|
||||
// The two forms of one module: its text inline, and its text in files/.
|
||||
const (
|
||||
inlineTwin = `{"module":"tidy","version":"1",
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[
|
||||
{"id":"conf","type":"directory","path":"${dir:conf}"},
|
||||
{"id":"main","type":"file","path":"${dir:conf}/tidy.conf","mode":"0644","content":"` + mainText + `"},
|
||||
{"id":"extra","type":"file","path":"${dir:conf}/extra.conf","content":"` + extraText + `"}]}`
|
||||
templatedTwin = `{"module":"tidy","version":"1",
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[
|
||||
{"id":"conf","type":"directory","path":"${dir:conf}"},
|
||||
{"id":"main","type":"file","path":"${dir:conf}/tidy.conf","mode":"0644","template":"files/tidy.conf"},
|
||||
{"id":"extra","type":"file","path":"${dir:conf}/extra.conf","template":"files/deeper/extra.conf"}]}`
|
||||
mainText = `# tidy\nroot = ${dir:conf}\nquoted = \"yes\" é\n`
|
||||
extraText = `one\ntwo\n`
|
||||
)
|
||||
|
||||
// templateTexts is what a build reads back from the packed files/ archive: by path inside it.
|
||||
func templateTexts(t *testing.T) map[string]string {
|
||||
t.Helper()
|
||||
var m struct{ Main, Extra string }
|
||||
if err := json.Unmarshal([]byte(`{"Main":"`+mainText+`","Extra":"`+extraText+`"}`), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return map[string]string{"tidy.conf": m.Main, "deeper/extra.conf": m.Extra, "unused.conf": "not named"}
|
||||
}
|
||||
|
||||
func builtFiles(texts map[string]string) []Built {
|
||||
return []Built{{Name: "files", Kind: ArtifactArchive,
|
||||
Reference: ArtifactStoreScheme + "tidy/files/blobs/sha256:" + strings.Repeat("c", 64),
|
||||
Digest: "sha256:" + strings.Repeat("c", 64), Texts: texts}}
|
||||
}
|
||||
|
||||
// held is a manifest as registration holds it: resolved with what was built, then read again strictly.
|
||||
func held(t *testing.T, raw string, built []Built) Manifest {
|
||||
t.Helper()
|
||||
m, err := ParseManifest([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err := m.Resolve(built)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := json.Marshal(resolved)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := ParseManifest(again)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if problem := HeldTemplateProblem(out); problem != "" {
|
||||
t.Fatal(problem)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func composed(t *testing.T, m Manifest) string {
|
||||
t.Helper()
|
||||
got, err := Resolve(shelf(m), []string{m.Module}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := json.Marshal(mustDeclare(t, got))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func TestATemplatedModuleComposesByteIdenticallyToItsInlineTwin(t *testing.T) {
|
||||
inline := composed(t, held(t, inlineTwin, builtFiles(nil)))
|
||||
templated := composed(t, held(t, templatedTwin, builtFiles(templateTexts(t))))
|
||||
if inline != templated {
|
||||
t.Fatalf("the two forms compose differently:\ninline: %s\ntemplated: %s", inline, templated)
|
||||
}
|
||||
if !strings.Contains(templated, "quoted = \\\"yes\\\"") || strings.Contains(templated, `"template"`) {
|
||||
t.Fatalf("the composition does not carry the template's text as content: %s", templated)
|
||||
}
|
||||
}
|
||||
|
||||
// What `module check`, the merge gate and `module add` judge: the text read from the tree beside the manifest.
|
||||
func TestATemplatedModuleReadFromItsTreeComposesByteIdenticallyToItsInlineTwin(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for name, text := range templateTexts(t) {
|
||||
full := filepath.Join(dir, "files", filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
m, err := ParseManifest([]byte(templatedTwin))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filled, err := WithTemplatesFrom(m, dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filled.Templated() {
|
||||
t.Fatal("a manifest read from its tree still names a template")
|
||||
}
|
||||
resolved, err := filled.Resolve(builtFiles(nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := composed(t, resolved), composed(t, held(t, inlineTwin, builtFiles(nil))); got != want {
|
||||
t.Fatalf("read from the tree, the module composes differently:\ninline: %s\ntemplated: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFileNamingBothATemplateAndContentIsRefused(t *testing.T) {
|
||||
raw := strings.Replace(templatedTwin, `"template":"files/tidy.conf"`,
|
||||
`"template":"files/tidy.conf","content":"x"`, 1)
|
||||
_, err := ParseManifest([]byte(raw))
|
||||
if err == nil || !strings.Contains(err.Error(), "main") || !strings.Contains(err.Error(), "never both") {
|
||||
t.Fatalf("a file with both a template and content was not refused by name: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATemplateOutsideFilesIsRefusedByName(t *testing.T) {
|
||||
for _, bad := range []string{"../tidy.conf", "/etc/tidy.conf", "files", "files/", "files/../tidy.conf",
|
||||
"files/./tidy.conf", "other/tidy.conf", "tidy.conf", "files//tidy.conf", `files\\tidy.conf`} {
|
||||
raw := strings.Replace(templatedTwin, `"files/tidy.conf"`, `"`+bad+`"`, 1)
|
||||
_, err := ParseManifest([]byte(raw))
|
||||
if err == nil || !strings.Contains(err.Error(), "main") || !strings.Contains(err.Error(), "not a file under files/") {
|
||||
t.Errorf("the template %q was not refused by name: %v", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestATemplateOnAResourceThatIsNotAFileIsRefused(t *testing.T) {
|
||||
raw := strings.Replace(templatedTwin, `{"id":"conf","type":"directory","path":"${dir:conf}"}`,
|
||||
`{"id":"conf","type":"directory","path":"${dir:conf}","template":"files/tidy.conf"}`, 1)
|
||||
_, err := ParseManifest([]byte(raw))
|
||||
if err == nil || !strings.Contains(err.Error(), "conf is a directory and names a template") {
|
||||
t.Fatalf("a directory naming a template was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATemplatedModuleWhoseBuildPacksNoFilesIsRefused(t *testing.T) {
|
||||
for _, build := range []string{
|
||||
``,
|
||||
`"build":{"artifacts":[{"name":"look","kind":"archive","from":"theme"}]},`,
|
||||
`"build":{"artifacts":[{"name":"a","kind":"archive","from":"files"},{"name":"b","kind":"archive","from":"./files/"}]},`,
|
||||
} {
|
||||
raw := strings.Replace(templatedTwin,
|
||||
`"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, build, 1)
|
||||
_, err := ParseManifest([]byte(raw))
|
||||
if err == nil || !strings.Contains(err.Error(), "files/") {
|
||||
t.Errorf("a templated module with the build %q was not refused: %v", build, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMissingTemplateIsRefusedByName(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(templatedTwin))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// In the tree beside the manifest.
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "files", "tidy.conf"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = WithTemplatesFrom(m, dir)
|
||||
if err == nil || !strings.Contains(err.Error(), "extra") || !strings.Contains(err.Error(), "files/deeper/extra.conf") {
|
||||
t.Fatalf("a template missing from the tree was not refused by name: %v", err)
|
||||
}
|
||||
// And in what the build read back from its archive.
|
||||
texts := templateTexts(t)
|
||||
delete(texts, "deeper/extra.conf")
|
||||
_, err = m.Resolve(builtFiles(texts))
|
||||
if err == nil || !strings.Contains(err.Error(), "extra") || !strings.Contains(err.Error(), "files/deeper/extra.conf") {
|
||||
t.Fatalf("a template missing from the archive was not refused by name: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATemplateThatIsASymlinkIsRefused(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(templatedTwin))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "files", "deeper"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "elsewhere"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "files", "tidy.conf"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(dir, "elsewhere"), filepath.Join(dir, "files", "deeper", "extra.conf")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := WithTemplatesFrom(m, dir); err == nil || !strings.Contains(err.Error(), "not a plain file") {
|
||||
t.Fatalf("a template that is a symlink was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAResolveWithoutTheArchivesTextIsRefused(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(templatedTwin))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.Resolve(builtFiles(nil)); err == nil || !strings.Contains(err.Error(), "files") {
|
||||
t.Fatalf("a templated module resolved with no text read back from its archive: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATemplateThatIsNotUTF8IsRefused(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(templatedTwin))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
texts := templateTexts(t)
|
||||
texts["tidy.conf"] = "\xff\xfe"
|
||||
if _, err := m.Resolve(builtFiles(texts)); err == nil || !strings.Contains(err.Error(), "UTF-8") {
|
||||
t.Fatalf("a template that is not UTF-8 was resolved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// What registration refuses: a manifest to be held that still names a template — one resolved by a builder that
|
||||
// predates templates would otherwise compose files with no text.
|
||||
func TestAManifestStillNamingATemplateIsNotHeld(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(templatedTwin))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if problem := HeldTemplateProblem(m); !strings.Contains(problem, "main") || !strings.Contains(problem, "files/tidy.conf") {
|
||||
t.Fatalf("a manifest naming a template would be held: %q", problem)
|
||||
}
|
||||
if problem := HeldTemplateProblem(held(t, inlineTwin, builtFiles(nil))); problem != "" {
|
||||
t.Fatalf("an inline manifest is refused: %s", problem)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user