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) } } // A symlinked files/, or a symlinked directory under it, would read a file outside the tree: refused by name, // as is any symlink or special file under files/ that no template names, which the build's packing refuses. func TestASymlinkAnywhereUnderFilesIsRefusedByName(t *testing.T) { m, err := ParseManifest([]byte(templatedTwin)) if err != nil { t.Fatal(err) } outside := t.TempDir() if err := os.MkdirAll(filepath.Join(outside, "deeper"), 0o755); err != nil { t.Fatal(err) } for _, name := range []string{"tidy.conf", "deeper/extra.conf", "secret"} { if err := os.WriteFile(filepath.Join(outside, filepath.FromSlash(name)), []byte("outside"), 0o644); err != nil { t.Fatal(err) } } write := func(t *testing.T, dir, name string) { t.Helper() full := filepath.Join(dir, filepath.FromSlash(name)) if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(full, []byte("inside"), 0o644); err != nil { t.Fatal(err) } } link := func(t *testing.T, target, at string) { t.Helper() if err := os.MkdirAll(filepath.Dir(at), 0o755); err != nil { t.Fatal(err) } if err := os.Symlink(target, at); err != nil { t.Fatal(err) } } for _, c := range []struct { name string make func(t *testing.T, dir string) names string }{ {"files/ itself", func(t *testing.T, dir string) { link(t, outside, filepath.Join(dir, "files")) }, "files/ is a symlink"}, {"a directory under files/", func(t *testing.T, dir string) { write(t, dir, "files/tidy.conf") link(t, filepath.Join(outside, "deeper"), filepath.Join(dir, "files", "deeper")) }, "files/deeper is a symlink"}, {"a file no template names", func(t *testing.T, dir string) { write(t, dir, "files/tidy.conf") write(t, dir, "files/deeper/extra.conf") link(t, filepath.Join(outside, "secret"), filepath.Join(dir, "files", "unnamed")) }, "files/unnamed is a symlink"}, } { dir := t.TempDir() c.make(t, dir) _, err := WithTemplatesFrom(m, dir) if err == nil || !strings.Contains(err.Error(), c.names) { t.Errorf("a symlinked %s was not refused by name: %v", c.name, err) } if err != nil && strings.Contains(err.Error(), "outside") { t.Errorf("a symlinked %s was read: %v", c.name, err) } } }