diff --git a/internal/build/manifest_tags_test.go b/internal/build/manifest_tags_test.go
new file mode 100644
index 0000000..840f4e6
--- /dev/null
+++ b/internal/build/manifest_tags_test.go
@@ -0,0 +1,189 @@
+package build
+
+import (
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+)
+
+// Unit tests (plan 05 of phase 19.3) for summer.yaml build.tags (D-12).
+
+const tagsManifestHead = "module: git.golem15.com/acme/acme\n" +
+ "binary: acme\n" +
+ "plugins:\n" +
+ " - id: acme.blog\n" +
+ " module: git.golem15.com/acme/acme/plugins/blog\n"
+
+func writeManifest(t *testing.T, body string) string {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), manifestName)
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+func TestManifestBuildTags(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ body string
+ want []string
+ }{
+ {"no build block", tagsManifestHead, nil},
+ {"empty build block", tagsManifestHead + "build: {}\n", nil},
+ {"null tags", tagsManifestHead + "build:\n tags:\n", nil},
+ {"empty list", tagsManifestHead + "build:\n tags: []\n", nil},
+ {"one tag", tagsManifestHead + "build:\n tags: [summercms_s3]\n", []string{"summercms_s3"}},
+ {"trimmed and deduplicated in order", tagsManifestHead + "build:\n tags:\n - \" netgo \"\n - summercms_s3\n - netgo\n - \"summercms_s3\\t\"\n - osusergo\n", []string{"netgo", "summercms_s3", "osusergo"}},
+ {"digits and underscores", tagsManifestHead + "build:\n tags: [go1_x, \"123\"]\n", []string{"go1_x", "123"}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ m, err := LoadManifest(writeManifest(t, tc.body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !slices.Equal(m.Tags, tc.want) {
+ t.Fatalf("Tags = %q, want %q", m.Tags, tc.want)
+ }
+ if len(m.Plugins) != 1 || m.Plugins[0].ID != "acme.blog" {
+ t.Fatalf("plugins = %+v", m.Plugins)
+ }
+ })
+ }
+ for _, tag := range []string{"summercms_s3", "a", "0", "x_1_y"} {
+ if err := ValidateBuildTag(tag); err != nil {
+ t.Errorf("ValidateBuildTag(%q) = %v", tag, err)
+ }
+ }
+}
+
+func TestManifestBuildTagsInvalid(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ yaml string
+ want string
+ }{
+ {"dash", "build:\n tags: [summercms-s3]\n", "build.tags[0]"},
+ {"space", "build:\n tags: [netgo, \"summercms s3\"]\n", "build.tags[1]"},
+ {"upper case", "build:\n tags: [SummerCMS_S3]\n", "build.tags[0]"},
+ {"flag injection", "build:\n tags: [\"x -ldflags=-X\"]\n", "build.tags[0]"},
+ {"comma", "build:\n tags: [\"a,b\"]\n", "build.tags[0]"},
+ {"empty string", "build:\n tags: [\" \"]\n", "build.tags[0]"},
+ {"non-string entry", "build:\n tags: [42]\n", "build.tags[0]"},
+ {"non-list tags", "build:\n tags: summercms_s3\n", "build.tags"},
+ {"mapping tags", "build:\n tags:\n s3: true\n", "build.tags"},
+ {"non-mapping build", "build: [summercms_s3]\n", "build.tags"},
+ {"scalar build", "build: summercms_s3\n", "build.tags"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ _, err := LoadManifest(writeManifest(t, tagsManifestHead+tc.yaml))
+ if err == nil || !strings.Contains(err.Error(), tc.want) {
+ t.Fatalf("err = %v, want one naming %s", err, tc.want)
+ }
+ })
+ }
+ for _, bad := range []string{"", "A", "a-b", "a b", "a.b", "-tags", "a,b", "รค"} {
+ if err := ValidateBuildTag(bad); err == nil {
+ t.Errorf("ValidateBuildTag(%q) accepted", bad)
+ }
+ }
+}
+
+func TestSaveManifestKeepsBuildTags(t *testing.T) {
+ path := writeManifest(t, tagsManifestHead+
+ " - id: acme.shop\n"+
+ " module: git.golem15.com/acme/acme/plugins/shop\n"+
+ "build:\n tags:\n - summercms_s3\n - netgo\n")
+ m, err := LoadManifest(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // What plugin:add does: append a plugin and save.
+ m.Plugins = append(m.Plugins, Plugin{ID: "acme.faq", Module: "git.golem15.com/acme/acme/plugins/faq"})
+ if err := SaveManifest(path, m); err != nil {
+ t.Fatal(err)
+ }
+ again, err := LoadManifest(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var ids []string
+ for _, p := range again.Plugins {
+ ids = append(ids, p.ID)
+ }
+ if !slices.Equal(ids, []string{"acme.blog", "acme.shop", "acme.faq"}) {
+ t.Fatalf("plugin order = %q", ids)
+ }
+ if !slices.Equal(again.Tags, []string{"summercms_s3", "netgo"}) {
+ t.Fatalf("Tags = %q after the round trip", again.Tags)
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasSuffix(string(raw), "build:\n tags:\n - summercms_s3\n - netgo\n") {
+ t.Fatalf("build block is not written after the plugins:\n%s", raw)
+ }
+
+ // No tags: no build block at all.
+ m.Tags = nil
+ if err := SaveManifest(path, m); err != nil {
+ t.Fatal(err)
+ }
+ raw, _ = os.ReadFile(path)
+ if strings.Contains(string(raw), "build:") {
+ t.Fatalf("empty tags wrote a build block:\n%s", raw)
+ }
+ m.Tags = []string{}
+ if err := SaveManifest(path, m); err != nil {
+ t.Fatal(err)
+ }
+ raw, _ = os.ReadFile(path)
+ if strings.Contains(string(raw), "build:") {
+ t.Fatalf("an empty tag list wrote a build block:\n%s", raw)
+ }
+
+ // SaveManifest refuses an invalid tag and leaves the file alone.
+ before, _ := os.ReadFile(path)
+ m.Tags = []string{"ok", "not-ok"}
+ if err := SaveManifest(path, m); err == nil || !strings.Contains(err.Error(), "build.tags[1]") {
+ t.Fatalf("SaveManifest with an invalid tag: %v", err)
+ }
+ after, _ := os.ReadFile(path)
+ if string(after) != string(before) {
+ t.Fatal("SaveManifest wrote a file with an invalid tag")
+ }
+}
+
+func TestGoBuildArgs(t *testing.T) {
+ if got := goBuildArgs("bin/acme", nil); !slices.Equal(got, []string{"build", "-o", "bin/acme", "."}) {
+ t.Fatalf("no tags = %q", got)
+ }
+ if got := goBuildArgs("bin/acme", []string{}); slices.ContainsFunc(got, func(a string) bool { return strings.HasPrefix(a, "-tags") }) {
+ t.Fatalf("empty tags = %q", got)
+ }
+ got := goBuildArgs("bin/acme", []string{"a", "b"})
+ if !slices.Equal(got, []string{"build", "-tags=a,b", "-o", "bin/acme", "."}) {
+ t.Fatalf("tags = %q", got)
+ }
+ n := 0
+ for _, a := range got {
+ if strings.HasPrefix(a, "-tags") {
+ n++
+ }
+ }
+ if n != 1 {
+ t.Fatalf("%d -tags arguments", n)
+ }
+
+ // The tags of a loaded manifest reach the arguments unchanged.
+ m, err := LoadManifest(writeManifest(t, tagsManifestHead+"build:\n tags: [summercms_s3, netgo]\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := goBuildArgs("bin/"+m.Binary, m.Tags); !slices.Equal(got, []string{"build", "-tags=summercms_s3,netgo", "-o", "bin/acme", "."}) {
+ t.Fatalf("manifest tags = %q", got)
+ }
+}
diff --git a/modules/lagoon/attach/diskext_test.go b/modules/lagoon/attach/diskext_test.go
new file mode 100644
index 0000000..82c3c25
--- /dev/null
+++ b/modules/lagoon/attach/diskext_test.go
@@ -0,0 +1,111 @@
+package attach
+
+import (
+ "net/http"
+ "slices"
+ "testing"
+)
+
+// TestDiskExtensionTable (plan 05 of phase 19.3, D-15) pins DiskExtension on
+// the RESEARCH probe rows and on the consistency of its tables: a client
+// extension that belongs to the sniffed type is kept, any other becomes the
+// type's canonical one, and a script-capable extension under a generic type
+// becomes txt (text/*) or bin.
+func TestDiskExtensionTable(t *testing.T) {
+ for _, tc := range []struct {
+ contentType, clientExt, want string
+ }{
+ // RESEARCH probe rows.
+ {"image/png", "html", "png"},
+ {"image/jpeg", "jpeg", "jpeg"},
+ {"image/jpeg", "jpg", "jpg"},
+ {"image/jpeg", "png", "jpg"},
+ {"video/webm", "mkv", "mkv"},
+ {"video/webm", "webm", "webm"},
+ {"application/zip", "docx", "docx"},
+ {"application/zip", "xlsx", "xlsx"},
+ {"text/plain; charset=utf-8", "ics", "ics"},
+ {"text/plain; charset=utf-8", "html", "txt"},
+ {"text/xml; charset=utf-8", "svg", "txt"},
+ {"application/octet-stream", "js", "bin"},
+ {"application/pdf", "txt", "pdf"},
+ // HTML bytes under their own name never stay html.
+ {"text/html; charset=utf-8", "html", "txt"},
+ {"text/html; charset=utf-8", "txt", "txt"},
+ // Normalisation: case, a leading dot, parameters and spaces.
+ {"IMAGE/PNG", ".PNG", "png"},
+ {" image/jpeg ; q=1", "JPE", "jpe"},
+ {"application/octet-stream", ".Docx", "docx"},
+ // Extensions outside [a-z0-9]{1,10} fall back like a script-capable one.
+ {"application/octet-stream", "", "bin"},
+ {"text/plain", "", "txt"},
+ {"application/octet-stream", "tar.gz", "bin"},
+ {"text/plain", "averyverylongext", "txt"},
+ {"application/octet-stream", "ex e", "bin"},
+ // Other specific types.
+ {"image/gif", "png", "gif"},
+ {"image/webp", "webp", "webp"},
+ {"application/ogg", "opus", "opus"},
+ {"audio/mpeg", "wav", "mp3"},
+ {"font/woff2", "woff2", "woff2"},
+ {"application/x-gzip", "tgz", "tgz"},
+ } {
+ if got := DiskExtension(tc.contentType, tc.clientExt); got != tc.want {
+ t.Errorf("DiskExtension(%q, %q) = %q, want %q", tc.contentType, tc.clientExt, got, tc.want)
+ }
+ }
+
+ // Every specific type keeps each of its own extensions, maps a
+ // script-capable one to its canonical extension, and lists no
+ // script-capable extension itself.
+ for ct, exts := range sniffedExtensions {
+ if len(exts) == 0 {
+ t.Fatalf("%s has no extensions", ct)
+ }
+ for _, ext := range exts {
+ if !extPattern.MatchString(ext) {
+ t.Errorf("%s: extension %q does not match the disk name pattern", ct, ext)
+ }
+ if slices.Contains(scriptCapableExtensions, ext) {
+ t.Errorf("%s: script-capable extension %q is kept", ct, ext)
+ }
+ if got := DiskExtension(ct, ext); got != ext {
+ t.Errorf("DiskExtension(%q, %q) = %q", ct, ext, got)
+ }
+ }
+ for _, bad := range scriptCapableExtensions {
+ if got := DiskExtension(ct, bad); got != exts[0] {
+ t.Errorf("DiskExtension(%q, %q) = %q, want %q", ct, bad, got, exts[0])
+ }
+ }
+ }
+
+ // Under every generic type a script-capable extension is rewritten.
+ for _, ct := range []string{"text/plain; charset=utf-8", "text/html; charset=utf-8", "text/xml; charset=utf-8", "text/css"} {
+ for _, bad := range scriptCapableExtensions {
+ if got := DiskExtension(ct, bad); got != "txt" {
+ t.Errorf("DiskExtension(%q, %q) = %q, want txt", ct, bad, got)
+ }
+ }
+ }
+ for _, ct := range []string{"application/octet-stream", "application/zip", "application/x-msdownload", ""} {
+ for _, bad := range scriptCapableExtensions {
+ if got := DiskExtension(ct, bad); got != "bin" {
+ t.Errorf("DiskExtension(%q, %q) = %q, want bin", ct, bad, got)
+ }
+ }
+ }
+
+ // The table agrees with what the standard library actually sniffs.
+ for body, want := range map[string]string{
+ "\x89PNG\r\n\x1a\n": "png",
+ "%PDF-1.7\n": "pdf",
+ "
x": "txt",
+ "