fix: tighten release and extension validation
- commit
- df3ccd37b755133af2def240f1c2229f2aa8e430
- author
- brice · · 2026-08-05 03:41 UTC
- parent
- 090c470
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +85 -38
M .github/workflows/ci.yml +1 -1
| @@ -26,7 +26,7 @@ jobs: | |||
| 26 | 26 | - name: set up go | |
| 27 | 27 | uses: actions/setup-go@v5 | |
| 28 | 28 | with: | |
| 29 | - | go-version: "1.23" | |
| 29 | + | go-version: "1.25" | |
| 30 | 30 | cache: true | |
| 31 | 31 | ||
| 32 | 32 | - name: go vet | |
| @@ -26,7 +26,7 @@ jobs: | |||||
| 26 | - name: set up go | 26 | - name: set up go | ||
| 27 | uses: actions/setup-go@v5 | 27 | uses: actions/setup-go@v5 | ||
| 28 | with: | 28 | with: | ||
| 29 | - | go-version: "1.23" | 29 | + | go-version: "1.25" |
| 30 | cache: true | 30 | cache: true | ||
| 31 | 31 | ||||
| 32 | - name: go vet | 32 | - name: go vet | ||
M .github/workflows/release.yml +1 -1
| @@ -89,7 +89,7 @@ jobs: | |||
| 89 | 89 | - name: set up go | |
| 90 | 90 | uses: actions/setup-go@v5 | |
| 91 | 91 | with: | |
| 92 | - | go-version: "1.23" | |
| 92 | + | go-version: "1.25" | |
| 93 | 93 | cache: true | |
| 94 | 94 | ||
| 95 | 95 | - name: run goreleaser | |
| @@ -89,7 +89,7 @@ jobs: | |||||
| 89 | - name: set up go | 89 | - name: set up go | ||
| 90 | uses: actions/setup-go@v5 | 90 | uses: actions/setup-go@v5 | ||
| 91 | with: | 91 | with: | ||
| 92 | - | go-version: "1.23" | 92 | + | go-version: "1.25" |
| 93 | cache: true | 93 | cache: true | ||
| 94 | 94 | ||||
| 95 | - name: run goreleaser | 95 | - name: run goreleaser | ||
M Makefile +6 -6
| @@ -66,13 +66,13 @@ clean: | |||
| 66 | 66 | rm -rf "$(BIN_DIR)" | |
| 67 | 67 | ||
| 68 | 68 | release: | |
| 69 | - | @if [ "$(VERSION)" = "$(DEV_VERSION)" ] || [ "$(VERSION)" = "0.0.0" ]; then \ | |
| 69 | + | @if [ "$(VERSION)" = "$(DEV_VERSION)" ] || [ "$(VERSION)" = "0.0.0" ] || printf '%s\n' "$(VERSION)" | grep -Eq '(^|[.-])[0-9]{14}-[A-Za-z0-9]+'; then \ | |
| 70 | 70 | echo "set VERSION to a release version, for example: make release VERSION=0.1.0" >&2; \ | |
| 71 | 71 | exit 1; \ | |
| 72 | 72 | fi | |
| 73 | 73 | @mkdir -p "$(BIN_DIR)" | |
| 74 | - | GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/zot-linux-amd64" ./cmd/zot | |
| 75 | - | GOOS=linux GOARCH=arm64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/zot-linux-arm64" ./cmd/zot | |
| 76 | - | GOOS=darwin GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/zot-darwin-amd64" ./cmd/zot | |
| 77 | - | GOOS=darwin GOARCH=arm64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/zot-darwin-arm64" ./cmd/zot | |
| 78 | - | GOOS=windows GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/zot-windows-amd64.exe" ./cmd/zot | |
| 74 | + | GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-linux-amd64" $(PKG) | |
| 75 | + | GOOS=linux GOARCH=arm64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-linux-arm64" $(PKG) | |
| 76 | + | GOOS=darwin GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-darwin-amd64" $(PKG) | |
| 77 | + | GOOS=darwin GOARCH=arm64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-darwin-arm64" $(PKG) | |
| 78 | + | GOOS=windows GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-windows-amd64.exe" $(PKG) | |
| @@ -66,13 +66,13 @@ clean: | |||||
| 66 | rm -rf "$(BIN_DIR)" | 66 | rm -rf "$(BIN_DIR)" | ||
| 67 | 67 | ||||
| 68 | release: | 68 | release: | ||
| 69 | - | @if [ "$(VERSION)" = "$(DEV_VERSION)" ] || [ "$(VERSION)" = "0.0.0" ]; then \ | 69 | + | @if [ "$(VERSION)" = "$(DEV_VERSION)" ] || [ "$(VERSION)" = "0.0.0" ] || printf '%s\n' "$(VERSION)" | grep -Eq '(^|[.-])[0-9]{14}-[A-Za-z0-9]+'; then \ |
| 70 | echo "set VERSION to a release version, for example: make release VERSION=0.1.0" >&2; \ | 70 | echo "set VERSION to a release version, for example: make release VERSION=0.1.0" >&2; \ | ||
| 71 | exit 1; \ | 71 | exit 1; \ | ||
| 72 | fi | 72 | fi | ||
| 73 | @mkdir -p "$(BIN_DIR)" | 73 | @mkdir -p "$(BIN_DIR)" | ||
| 74 | - | GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/zot-linux-amd64" ./cmd/zot | 74 | + | GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-linux-amd64" $(PKG) |
| 75 | - | GOOS=linux GOARCH=arm64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/zot-linux-arm64" ./cmd/zot | 75 | + | GOOS=linux GOARCH=arm64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-linux-arm64" $(PKG) |
| 76 | - | GOOS=darwin GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/zot-darwin-amd64" ./cmd/zot | 76 | + | GOOS=darwin GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-darwin-amd64" $(PKG) |
| 77 | - | GOOS=darwin GOARCH=arm64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/zot-darwin-arm64" ./cmd/zot | 77 | + | GOOS=darwin GOARCH=arm64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-darwin-arm64" $(PKG) |
| 78 | - | GOOS=windows GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/zot-windows-amd64.exe" ./cmd/zot | 78 | + | GOOS=windows GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o "$(BIN_DIR)/$(BINARY)-windows-amd64.exe" $(PKG) |
M cmd/zot/main.go +4 -18
| @@ -4,6 +4,7 @@ package main | |||
| 4 | 4 | import ( | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "os" | |
| 7 | + | "regexp" | |
| 7 | 8 | "runtime/debug" | |
| 8 | 9 | "strings" | |
| 9 | 10 | ||
| @@ -59,23 +60,8 @@ func resolvedVersionFromBuildInfo(linkedVersion string, info *debug.BuildInfo) s | |||
| 59 | 60 | return strings.TrimPrefix(info.Main.Version, "v") | |
| 60 | 61 | } | |
| 61 | 62 | ||
| 63 | + | var pseudoVersionPattern = regexp.MustCompile(`^v[0-9]+\.(0\.0-|[0-9]+\.[0-9]+-([^+]*\.)?0\.)[0-9]{14}-[A-Za-z0-9]+(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`) | |
| 64 | + | ||
| 62 | 65 | func isPseudoVersion(version string) bool { | |
| 63 | - | version = strings.TrimPrefix(version, "v") | |
| 64 | - | parts := strings.Split(version, "-") | |
| 65 | - | if len(parts) < 3 { | |
| 66 | - | return false | |
| 67 | - | } | |
| 68 | - | timestamp := parts[len(parts)-2] | |
| 69 | - | if strings.HasPrefix(timestamp, "0.") { | |
| 70 | - | timestamp = timestamp[2:] | |
| 71 | - | } | |
| 72 | - | if len(timestamp) != 14 { | |
| 73 | - | return false | |
| 74 | - | } | |
| 75 | - | for _, ch := range timestamp { | |
| 76 | - | if ch < '0' || ch > '9' { | |
| 77 | - | return false | |
| 78 | - | } | |
| 79 | - | } | |
| 80 | - | return true | |
| 66 | + | return pseudoVersionPattern.MatchString(version) | |
| 81 | 67 | } | |
| @@ -4,6 +4,7 @@ package main | |||||
| 4 | import ( | 4 | import ( | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "os" | 6 | "os" | ||
| 7 | + | "regexp" | |||
| 7 | "runtime/debug" | 8 | "runtime/debug" | ||
| 8 | "strings" | 9 | "strings" | ||
| 9 | 10 | ||||
| @@ -59,23 +60,8 @@ func resolvedVersionFromBuildInfo(linkedVersion string, info *debug.BuildInfo) s | |||||
| 59 | return strings.TrimPrefix(info.Main.Version, "v") | 60 | return strings.TrimPrefix(info.Main.Version, "v") | ||
| 60 | } | 61 | } | ||
| 61 | 62 | ||||
| 63 | + | var pseudoVersionPattern = regexp.MustCompile(`^v[0-9]+\.(0\.0-|[0-9]+\.[0-9]+-([^+]*\.)?0\.)[0-9]{14}-[A-Za-z0-9]+(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`) | |||
| 64 | + | ||||
| 62 | func isPseudoVersion(version string) bool { | 65 | func isPseudoVersion(version string) bool { | ||
| 63 | - | version = strings.TrimPrefix(version, "v") | 66 | + | return pseudoVersionPattern.MatchString(version) |
| 64 | - | parts := strings.Split(version, "-") | |||
| 65 | - | if len(parts) < 3 { | |||
| 66 | - | return false | |||
| 67 | - | } | |||
| 68 | - | timestamp := parts[len(parts)-2] | |||
| 69 | - | if strings.HasPrefix(timestamp, "0.") { | |||
| 70 | - | timestamp = timestamp[2:] | |||
| 71 | - | } | |||
| 72 | - | if len(timestamp) != 14 { | |||
| 73 | - | return false | |||
| 74 | - | } | |||
| 75 | - | for _, ch := range timestamp { | |||
| 76 | - | if ch < '0' || ch > '9' { | |||
| 77 | - | return false | |||
| 78 | - | } | |||
| 79 | - | } | |||
| 80 | - | return true | |||
| 81 | } | 67 | } | ||
M cmd/zot/main_test.go +23 -0
| @@ -5,6 +5,29 @@ import ( | |||
| 5 | 5 | "testing" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | + | func TestIsPseudoVersion(t *testing.T) { | |
| 9 | + | for _, version := range []string{ | |
| 10 | + | "v0.0.0-20260804145622-bce908a66c9a", | |
| 11 | + | "v0.2.94-0.20260804145622-bce908a66c9a", | |
| 12 | + | "v0.2.94-pre.0.20260804145622-bce908a66c9a", | |
| 13 | + | "v0.2.94-0.20260804145622-bce908a66c9a+incompatible", | |
| 14 | + | } { | |
| 15 | + | if !isPseudoVersion(version) { | |
| 16 | + | t.Errorf("isPseudoVersion(%q) = false, want true", version) | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | for _, version := range []string{ | |
| 20 | + | "v0.2.95-20260804145622-abcdef", | |
| 21 | + | "v0.2.94-0.2026080414562-bce908a66c9a", | |
| 22 | + | "v0.2.94-0.20260804145622", | |
| 23 | + | "0.2.94-0.20260804145622-bce908a66c9a", | |
| 24 | + | } { | |
| 25 | + | if isPseudoVersion(version) { | |
| 26 | + | t.Errorf("isPseudoVersion(%q) = true, want false", version) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 8 | 31 | func TestResolvedVersionFromBuildInfo(t *testing.T) { | |
| 9 | 32 | tests := []struct { | |
| 10 | 33 | name string | |
| @@ -5,6 +5,29 @@ import ( | |||||
| 5 | "testing" | 5 | "testing" | ||
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | + | func TestIsPseudoVersion(t *testing.T) { | |||
| 9 | + | for _, version := range []string{ | |||
| 10 | + | "v0.0.0-20260804145622-bce908a66c9a", | |||
| 11 | + | "v0.2.94-0.20260804145622-bce908a66c9a", | |||
| 12 | + | "v0.2.94-pre.0.20260804145622-bce908a66c9a", | |||
| 13 | + | "v0.2.94-0.20260804145622-bce908a66c9a+incompatible", | |||
| 14 | + | } { | |||
| 15 | + | if !isPseudoVersion(version) { | |||
| 16 | + | t.Errorf("isPseudoVersion(%q) = false, want true", version) | |||
| 17 | + | } | |||
| 18 | + | } | |||
| 19 | + | for _, version := range []string{ | |||
| 20 | + | "v0.2.95-20260804145622-abcdef", | |||
| 21 | + | "v0.2.94-0.2026080414562-bce908a66c9a", | |||
| 22 | + | "v0.2.94-0.20260804145622", | |||
| 23 | + | "0.2.94-0.20260804145622-bce908a66c9a", | |||
| 24 | + | } { | |||
| 25 | + | if isPseudoVersion(version) { | |||
| 26 | + | t.Errorf("isPseudoVersion(%q) = true, want false", version) | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 8 | func TestResolvedVersionFromBuildInfo(t *testing.T) { | 31 | func TestResolvedVersionFromBuildInfo(t *testing.T) { | ||
| 9 | tests := []struct { | 32 | tests := []struct { | ||
| 10 | name string | 33 | name string | ||
M docs/extensions.md +2 -1
| @@ -84,7 +84,8 @@ The `examples/extensions/` directory in the repo is reference code, not a defaul | |||
| 84 | 84 | # install a Go example and explicitly build it in the staged install | |
| 85 | 85 | zot ext install --build=go path/to/zot/examples/extensions/hello | |
| 86 | 86 | ||
| 87 | - | # or load straight from the repo for one zot session | |
| 87 | + | # or build it in the source tree for a one-run development load | |
| 88 | + | cd path/to/zot/examples/extensions/hello && go build -o hello . | |
| 88 | 89 | zot --ext path/to/zot/examples/extensions/hello | |
| 89 | 90 | ``` | |
| 90 | 91 | ||
| @@ -84,7 +84,8 @@ The `examples/extensions/` directory in the repo is reference code, not a defaul | |||||
| 84 | # install a Go example and explicitly build it in the staged install | 84 | # install a Go example and explicitly build it in the staged install | ||
| 85 | zot ext install --build=go path/to/zot/examples/extensions/hello | 85 | zot ext install --build=go path/to/zot/examples/extensions/hello | ||
| 86 | 86 | ||||
| 87 | - | # or load straight from the repo for one zot session | 87 | + | # or build it in the source tree for a one-run development load |
| 88 | + | cd path/to/zot/examples/extensions/hello && go build -o hello . | |||
| 88 | zot --ext path/to/zot/examples/extensions/hello | 89 | zot --ext path/to/zot/examples/extensions/hello | ||
| 89 | ``` | 90 | ``` | ||
| 90 | 91 | ||||
M packages/agent/args.go +2 -1
| @@ -423,7 +423,8 @@ func PrintHelp(version string) { | |||
| 423 | 423 | ) | |
| 424 | 424 | section("extensions", | |
| 425 | 425 | row{"zot ext list", "list installed extensions"}, | |
| 426 | - | row{"zot ext install [--build=go] <path|url>", "install; explicitly build local Go source"}, | |
| 426 | + | row{"zot ext install <path|url>", "install from a local path or URL"}, | |
| 427 | + | row{"zot ext install --build=go <path>", "build local Go source, then install"}, | |
| 427 | 428 | row{"zot --ext ./path/to/ext", "load an extension for this run only"}, | |
| 428 | 429 | row{"zot ext help", "show all extension subcommands"}, | |
| 429 | 430 | ) | |
| @@ -423,7 +423,8 @@ func PrintHelp(version string) { | |||||
| 423 | ) | 423 | ) | ||
| 424 | section("extensions", | 424 | section("extensions", | ||
| 425 | row{"zot ext list", "list installed extensions"}, | 425 | row{"zot ext list", "list installed extensions"}, | ||
| 426 | - | row{"zot ext install [--build=go] <path|url>", "install; explicitly build local Go source"}, | 426 | + | row{"zot ext install <path|url>", "install from a local path or URL"}, |
| 427 | + | row{"zot ext install --build=go <path>", "build local Go source, then install"}, | |||
| 427 | row{"zot --ext ./path/to/ext", "load an extension for this run only"}, | 428 | row{"zot --ext ./path/to/ext", "load an extension for this run only"}, | ||
| 428 | row{"zot ext help", "show all extension subcommands"}, | 429 | row{"zot ext help", "show all extension subcommands"}, | ||
| 429 | ) | 430 | ) | ||
M packages/agent/extcmd.go +8 -9
| @@ -92,7 +92,7 @@ func extList() error { | |||
| 92 | 92 | continue | |
| 93 | 93 | } | |
| 94 | 94 | for _, e := range entries { | |
| 95 | - | if !e.IsDir() { | |
| 95 | + | if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { | |
| 96 | 96 | continue | |
| 97 | 97 | } | |
| 98 | 98 | extDir := filepath.Join(dir, e.Name()) | |
| @@ -223,7 +223,7 @@ func scanExtDoctorStatic() []extDoctorStaticRow { | |||
| 223 | 223 | } | |
| 224 | 224 | sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) | |
| 225 | 225 | for _, e := range entries { | |
| 226 | - | if !e.IsDir() { | |
| 226 | + | if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { | |
| 227 | 227 | continue | |
| 228 | 228 | } | |
| 229 | 229 | extDir := filepath.Join(sd.dir, e.Name()) | |
| @@ -523,7 +523,7 @@ func extInstall(args []string) error { | |||
| 523 | 523 | return fmt.Errorf("copy extension: %w", err) | |
| 524 | 524 | } | |
| 525 | 525 | if opts.builder != "" { | |
| 526 | - | if err := buildLocalExtension(opts.builder, absSrc, staged, manifest); err != nil { | |
| 526 | + | if err := buildLocalExtension(absSrc, staged, manifest); err != nil { | |
| 527 | 527 | return err | |
| 528 | 528 | } | |
| 529 | 529 | } | |
| @@ -591,14 +591,10 @@ func parseExtInstallArgs(args []string) (extInstallOptions, error) { | |||
| 591 | 591 | return opts, nil | |
| 592 | 592 | } | |
| 593 | 593 | ||
| 594 | - | func buildLocalExtension(builder, sourceDir, stagedDir string, manifest extensions.Manifest) error { | |
| 594 | + | func buildLocalExtension(sourceDir, stagedDir string, manifest extensions.Manifest) error { | |
| 595 | 595 | if manifest.Exec == "" { | |
| 596 | 596 | return errors.New("cannot build a theme-only extension; it does not declare an executable") | |
| 597 | 597 | } | |
| 598 | - | if builder != "go" { | |
| 599 | - | return fmt.Errorf("unsupported extension builder %q (currently supported: go)", builder) | |
| 600 | - | } | |
| 601 | - | ||
| 602 | 598 | execRel, err := extensionRelativeExecPath(sourceDir, manifest.Exec) | |
| 603 | 599 | if err != nil { | |
| 604 | 600 | return fmt.Errorf("%w: --build=go requires an extension-relative executable path", err) | |
| @@ -685,6 +681,9 @@ func readExtensionManifest(dir string) (extensions.Manifest, error) { | |||
| 685 | 681 | if strings.TrimSpace(manifest.Name) == "" { | |
| 686 | 682 | return extensions.Manifest{}, errors.New("manifest: name is required") | |
| 687 | 683 | } | |
| 684 | + | if manifest.Name != strings.TrimSpace(manifest.Name) { | |
| 685 | + | return extensions.Manifest{}, fmt.Errorf("manifest: extension name %q has leading or trailing whitespace", manifest.Name) | |
| 686 | + | } | |
| 688 | 687 | if manifest.Name == "." || manifest.Name == ".." || strings.ContainsAny(manifest.Name, `/\\`) { | |
| 689 | 688 | return extensions.Manifest{}, fmt.Errorf("manifest: invalid extension name %q", manifest.Name) | |
| 690 | 689 | } | |
| @@ -744,7 +743,7 @@ func validateExtensionExecutable(dir string, manifest extensions.Manifest) error | |||
| 744 | 743 | return fmt.Errorf("extension executable %q is a directory", manifest.Exec) | |
| 745 | 744 | } | |
| 746 | 745 | if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { | |
| 747 | - | return fmt.Errorf("extension executable %q is not executable; run chmod +x %s before installing", manifest.Exec, filepath.Base(path)) | |
| 746 | + | return fmt.Errorf("extension executable %q is not executable; run chmod +x on it in the source directory before installing", manifest.Exec) | |
| 748 | 747 | } | |
| 749 | 748 | return nil | |
| 750 | 749 | } | |
| @@ -92,7 +92,7 @@ func extList() error { | |||||
| 92 | continue | 92 | continue | ||
| 93 | } | 93 | } | ||
| 94 | for _, e := range entries { | 94 | for _, e := range entries { | ||
| 95 | - | if !e.IsDir() { | 95 | + | if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { |
| 96 | continue | 96 | continue | ||
| 97 | } | 97 | } | ||
| 98 | extDir := filepath.Join(dir, e.Name()) | 98 | extDir := filepath.Join(dir, e.Name()) | ||
| @@ -223,7 +223,7 @@ func scanExtDoctorStatic() []extDoctorStaticRow { | |||||
| 223 | } | 223 | } | ||
| 224 | sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) | 224 | sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) | ||
| 225 | for _, e := range entries { | 225 | for _, e := range entries { | ||
| 226 | - | if !e.IsDir() { | 226 | + | if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { |
| 227 | continue | 227 | continue | ||
| 228 | } | 228 | } | ||
| 229 | extDir := filepath.Join(sd.dir, e.Name()) | 229 | extDir := filepath.Join(sd.dir, e.Name()) | ||
| @@ -523,7 +523,7 @@ func extInstall(args []string) error { | |||||
| 523 | return fmt.Errorf("copy extension: %w", err) | 523 | return fmt.Errorf("copy extension: %w", err) | ||
| 524 | } | 524 | } | ||
| 525 | if opts.builder != "" { | 525 | if opts.builder != "" { | ||
| 526 | - | if err := buildLocalExtension(opts.builder, absSrc, staged, manifest); err != nil { | 526 | + | if err := buildLocalExtension(absSrc, staged, manifest); err != nil { |
| 527 | return err | 527 | return err | ||
| 528 | } | 528 | } | ||
| 529 | } | 529 | } | ||
| @@ -591,14 +591,10 @@ func parseExtInstallArgs(args []string) (extInstallOptions, error) { | |||||
| 591 | return opts, nil | 591 | return opts, nil | ||
| 592 | } | 592 | } | ||
| 593 | 593 | ||||
| 594 | - | func buildLocalExtension(builder, sourceDir, stagedDir string, manifest extensions.Manifest) error { | 594 | + | func buildLocalExtension(sourceDir, stagedDir string, manifest extensions.Manifest) error { |
| 595 | if manifest.Exec == "" { | 595 | if manifest.Exec == "" { | ||
| 596 | return errors.New("cannot build a theme-only extension; it does not declare an executable") | 596 | return errors.New("cannot build a theme-only extension; it does not declare an executable") | ||
| 597 | } | 597 | } | ||
| 598 | - | if builder != "go" { | |||
| 599 | - | return fmt.Errorf("unsupported extension builder %q (currently supported: go)", builder) | |||
| 600 | - | } | |||
| 601 | - | ||||
| 602 | execRel, err := extensionRelativeExecPath(sourceDir, manifest.Exec) | 598 | execRel, err := extensionRelativeExecPath(sourceDir, manifest.Exec) | ||
| 603 | if err != nil { | 599 | if err != nil { | ||
| 604 | return fmt.Errorf("%w: --build=go requires an extension-relative executable path", err) | 600 | return fmt.Errorf("%w: --build=go requires an extension-relative executable path", err) | ||
| @@ -685,6 +681,9 @@ func readExtensionManifest(dir string) (extensions.Manifest, error) { | |||||
| 685 | if strings.TrimSpace(manifest.Name) == "" { | 681 | if strings.TrimSpace(manifest.Name) == "" { | ||
| 686 | return extensions.Manifest{}, errors.New("manifest: name is required") | 682 | return extensions.Manifest{}, errors.New("manifest: name is required") | ||
| 687 | } | 683 | } | ||
| 684 | + | if manifest.Name != strings.TrimSpace(manifest.Name) { | |||
| 685 | + | return extensions.Manifest{}, fmt.Errorf("manifest: extension name %q has leading or trailing whitespace", manifest.Name) | |||
| 686 | + | } | |||
| 688 | if manifest.Name == "." || manifest.Name == ".." || strings.ContainsAny(manifest.Name, `/\\`) { | 687 | if manifest.Name == "." || manifest.Name == ".." || strings.ContainsAny(manifest.Name, `/\\`) { | ||
| 689 | return extensions.Manifest{}, fmt.Errorf("manifest: invalid extension name %q", manifest.Name) | 688 | return extensions.Manifest{}, fmt.Errorf("manifest: invalid extension name %q", manifest.Name) | ||
| 690 | } | 689 | } | ||
| @@ -744,7 +743,7 @@ func validateExtensionExecutable(dir string, manifest extensions.Manifest) error | |||||
| 744 | return fmt.Errorf("extension executable %q is a directory", manifest.Exec) | 743 | return fmt.Errorf("extension executable %q is a directory", manifest.Exec) | ||
| 745 | } | 744 | } | ||
| 746 | if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { | 745 | if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { | ||
| 747 | - | return fmt.Errorf("extension executable %q is not executable; run chmod +x %s before installing", manifest.Exec, filepath.Base(path)) | 746 | + | return fmt.Errorf("extension executable %q is not executable; run chmod +x on it in the source directory before installing", manifest.Exec) |
| 748 | } | 747 | } | ||
| 749 | return nil | 748 | return nil | ||
| 750 | } | 749 | } | ||
M packages/agent/extcmd_test.go +18 -0
| @@ -215,6 +215,7 @@ func TestParseExtInstallArgs(t *testing.T) { | |||
| 215 | 215 | {name: "equals", args: []string{"--build=go", "./ext"}, builder: "go"}, | |
| 216 | 216 | {name: "separate", args: []string{"./ext", "--build", "go"}, builder: "go"}, | |
| 217 | 217 | {name: "missing builder", args: []string{"--build=", "./ext"}, wantErr: "requires a builder"}, | |
| 218 | + | {name: "missing separate builder", args: []string{"./ext", "--build"}, wantErr: "requires a builder"}, | |
| 218 | 219 | {name: "missing source", args: []string{"--build=go"}, wantErr: "usage:"}, | |
| 219 | 220 | {name: "extra source", args: []string{"./one", "./two"}, wantErr: "usage:"}, | |
| 220 | 221 | {name: "unknown option", args: []string{"--wat", "./ext"}, wantErr: "unknown ext install option"}, | |
| @@ -260,6 +261,23 @@ func TestExtInstallRejectsBuildForThemeOnly(t *testing.T) { | |||
| 260 | 261 | } | |
| 261 | 262 | } | |
| 262 | 263 | ||
| 264 | + | func TestExtInstallRejectsWhitespaceManifestName(t *testing.T) { | |
| 265 | + | home := t.TempDir() | |
| 266 | + | t.Setenv("ZOT_HOME", home) | |
| 267 | + | src := filepath.Join(t.TempDir(), "whitespace-name") | |
| 268 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |
| 269 | + | t.Fatal(err) | |
| 270 | + | } | |
| 271 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":" whitespace-name ","exec":"go"}`), 0o644); err != nil { | |
| 272 | + | t.Fatal(err) | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | err := extInstall([]string{src}) | |
| 276 | + | if err == nil || !strings.Contains(err.Error(), "leading or trailing whitespace") { | |
| 277 | + | t.Fatalf("error = %v, want whitespace validation error", err) | |
| 278 | + | } | |
| 279 | + | } | |
| 280 | + | ||
| 263 | 281 | func TestExtInstallRejectsBuildForPATHExecutable(t *testing.T) { | |
| 264 | 282 | home := t.TempDir() | |
| 265 | 283 | t.Setenv("ZOT_HOME", home) | |
| @@ -215,6 +215,7 @@ func TestParseExtInstallArgs(t *testing.T) { | |||||
| 215 | {name: "equals", args: []string{"--build=go", "./ext"}, builder: "go"}, | 215 | {name: "equals", args: []string{"--build=go", "./ext"}, builder: "go"}, | ||
| 216 | {name: "separate", args: []string{"./ext", "--build", "go"}, builder: "go"}, | 216 | {name: "separate", args: []string{"./ext", "--build", "go"}, builder: "go"}, | ||
| 217 | {name: "missing builder", args: []string{"--build=", "./ext"}, wantErr: "requires a builder"}, | 217 | {name: "missing builder", args: []string{"--build=", "./ext"}, wantErr: "requires a builder"}, | ||
| 218 | + | {name: "missing separate builder", args: []string{"./ext", "--build"}, wantErr: "requires a builder"}, | |||
| 218 | {name: "missing source", args: []string{"--build=go"}, wantErr: "usage:"}, | 219 | {name: "missing source", args: []string{"--build=go"}, wantErr: "usage:"}, | ||
| 219 | {name: "extra source", args: []string{"./one", "./two"}, wantErr: "usage:"}, | 220 | {name: "extra source", args: []string{"./one", "./two"}, wantErr: "usage:"}, | ||
| 220 | {name: "unknown option", args: []string{"--wat", "./ext"}, wantErr: "unknown ext install option"}, | 221 | {name: "unknown option", args: []string{"--wat", "./ext"}, wantErr: "unknown ext install option"}, | ||
| @@ -260,6 +261,23 @@ func TestExtInstallRejectsBuildForThemeOnly(t *testing.T) { | |||||
| 260 | } | 261 | } | ||
| 261 | } | 262 | } | ||
| 262 | 263 | ||||
| 264 | + | func TestExtInstallRejectsWhitespaceManifestName(t *testing.T) { | |||
| 265 | + | home := t.TempDir() | |||
| 266 | + | t.Setenv("ZOT_HOME", home) | |||
| 267 | + | src := filepath.Join(t.TempDir(), "whitespace-name") | |||
| 268 | + | if err := os.MkdirAll(src, 0o755); err != nil { | |||
| 269 | + | t.Fatal(err) | |||
| 270 | + | } | |||
| 271 | + | if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":" whitespace-name ","exec":"go"}`), 0o644); err != nil { | |||
| 272 | + | t.Fatal(err) | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | err := extInstall([]string{src}) | |||
| 276 | + | if err == nil || !strings.Contains(err.Error(), "leading or trailing whitespace") { | |||
| 277 | + | t.Fatalf("error = %v, want whitespace validation error", err) | |||
| 278 | + | } | |||
| 279 | + | } | |||
| 280 | + | ||||
| 263 | func TestExtInstallRejectsBuildForPATHExecutable(t *testing.T) { | 281 | func TestExtInstallRejectsBuildForPATHExecutable(t *testing.T) { | ||
| 264 | home := t.TempDir() | 282 | home := t.TempDir() | ||
| 265 | t.Setenv("ZOT_HOME", home) | 283 | t.Setenv("ZOT_HOME", home) | ||
M packages/agent/extensions/manager.go +1 -1
| @@ -279,7 +279,7 @@ func (m *Manager) Discover(ctx context.Context) []error { | |||
| 279 | 279 | continue // missing directory is fine | |
| 280 | 280 | } | |
| 281 | 281 | for _, e := range entries { | |
| 282 | - | if !e.IsDir() { | |
| 282 | + | if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { | |
| 283 | 283 | continue | |
| 284 | 284 | } | |
| 285 | 285 | if seenDirs[e.Name()] { | |
| @@ -279,7 +279,7 @@ func (m *Manager) Discover(ctx context.Context) []error { | |||||
| 279 | continue // missing directory is fine | 279 | continue // missing directory is fine | ||
| 280 | } | 280 | } | ||
| 281 | for _, e := range entries { | 281 | for _, e := range entries { | ||
| 282 | - | if !e.IsDir() { | 282 | + | if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { |
| 283 | continue | 283 | continue | ||
| 284 | } | 284 | } | ||
| 285 | if seenDirs[e.Name()] { | 285 | if seenDirs[e.Name()] { | ||
M packages/agent/update_test.go +19 -0
| @@ -2,6 +2,8 @@ package agent | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 6 | + | "net/http" | |
| 5 | 7 | "os" | |
| 6 | 8 | "path/filepath" | |
| 7 | 9 | "testing" | |
| @@ -20,8 +22,22 @@ func TestIsDevVersion(t *testing.T) { | |||
| 20 | 22 | } | |
| 21 | 23 | } | |
| 22 | 24 | ||
| 25 | + | type countingRoundTripper struct { | |
| 26 | + | calls int | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func (r *countingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { | |
| 30 | + | r.calls++ | |
| 31 | + | return nil, errors.New("unexpected network request") | |
| 32 | + | } | |
| 33 | + | ||
| 23 | 34 | func TestCheckForUpdateSkipsDevVersionWithoutCacheOrNetwork(t *testing.T) { | |
| 24 | 35 | home := t.TempDir() | |
| 36 | + | transport := &countingRoundTripper{} | |
| 37 | + | previousTransport := http.DefaultTransport | |
| 38 | + | http.DefaultTransport = transport | |
| 39 | + | t.Cleanup(func() { http.DefaultTransport = previousTransport }) | |
| 40 | + | ||
| 25 | 41 | ctx, cancel := context.WithCancel(context.Background()) | |
| 26 | 42 | cancel() | |
| 27 | 43 | ||
| @@ -32,4 +48,7 @@ func TestCheckForUpdateSkipsDevVersionWithoutCacheOrNetwork(t *testing.T) { | |||
| 32 | 48 | if _, err := os.Stat(filepath.Join(home, updateCheckFile)); !os.IsNotExist(err) { | |
| 33 | 49 | t.Fatalf("development check wrote a cache file: %v", err) | |
| 34 | 50 | } | |
| 51 | + | if transport.calls != 0 { | |
| 52 | + | t.Fatalf("development check made %d network requests, want 0", transport.calls) | |
| 53 | + | } | |
| 35 | 54 | } | |
| @@ -2,6 +2,8 @@ package agent | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 6 | + | "net/http" | |||
| 5 | "os" | 7 | "os" | ||
| 6 | "path/filepath" | 8 | "path/filepath" | ||
| 7 | "testing" | 9 | "testing" | ||
| @@ -20,8 +22,22 @@ func TestIsDevVersion(t *testing.T) { | |||||
| 20 | } | 22 | } | ||
| 21 | } | 23 | } | ||
| 22 | 24 | ||||
| 25 | + | type countingRoundTripper struct { | |||
| 26 | + | calls int | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func (r *countingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { | |||
| 30 | + | r.calls++ | |||
| 31 | + | return nil, errors.New("unexpected network request") | |||
| 32 | + | } | |||
| 33 | + | ||||
| 23 | func TestCheckForUpdateSkipsDevVersionWithoutCacheOrNetwork(t *testing.T) { | 34 | func TestCheckForUpdateSkipsDevVersionWithoutCacheOrNetwork(t *testing.T) { | ||
| 24 | home := t.TempDir() | 35 | home := t.TempDir() | ||
| 36 | + | transport := &countingRoundTripper{} | |||
| 37 | + | previousTransport := http.DefaultTransport | |||
| 38 | + | http.DefaultTransport = transport | |||
| 39 | + | t.Cleanup(func() { http.DefaultTransport = previousTransport }) | |||
| 40 | + | ||||
| 25 | ctx, cancel := context.WithCancel(context.Background()) | 41 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 26 | cancel() | 42 | cancel() | ||
| 27 | 43 | ||||
| @@ -32,4 +48,7 @@ func TestCheckForUpdateSkipsDevVersionWithoutCacheOrNetwork(t *testing.T) { | |||||
| 32 | if _, err := os.Stat(filepath.Join(home, updateCheckFile)); !os.IsNotExist(err) { | 48 | if _, err := os.Stat(filepath.Join(home, updateCheckFile)); !os.IsNotExist(err) { | ||
| 33 | t.Fatalf("development check wrote a cache file: %v", err) | 49 | t.Fatalf("development check wrote a cache file: %v", err) | ||
| 34 | } | 50 | } | ||
| 51 | + | if transport.calls != 0 { | |||
| 52 | + | t.Fatalf("development check made %d network requests, want 0", transport.calls) | |||
| 53 | + | } | |||
| 35 | } | 54 | } | ||