detached df3ccd3

fix: tighten release and extension validation

commit
df3ccd37b755133af2def240f1c2229f2aa8e430
author
brice · · 2026-08-05 03:41 UTC
parent
090c470
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +85 -38
M .github/workflows/ci.yml +1 -1
@@ -26,7 +26,7 @@ jobs:
2626 - name: set up go
2727 uses: actions/setup-go@v5
2828 with:
29- go-version: "1.23"
29+ go-version: "1.25"
3030 cache: true
3131
3232 - name: go vet
@@ -26,7 +26,7 @@ jobs:
26 - name: set up go26 - name: set up go
27 uses: actions/setup-go@v527 uses: actions/setup-go@v5
28 with:28 with:
29- go-version: "1.23"29+ go-version: "1.25"
30 cache: true30 cache: true
31 31
32 - name: go vet32 - name: go vet
M .github/workflows/release.yml +1 -1
@@ -89,7 +89,7 @@ jobs:
8989 - name: set up go
9090 uses: actions/setup-go@v5
9191 with:
92- go-version: "1.23"
92+ go-version: "1.25"
9393 cache: true
9494
9595 - name: run goreleaser
@@ -89,7 +89,7 @@ jobs:
89 - name: set up go89 - name: set up go
90 uses: actions/setup-go@v590 uses: actions/setup-go@v5
91 with:91 with:
92- go-version: "1.23"92+ go-version: "1.25"
93 cache: true93 cache: true
94 94
95 - name: run goreleaser95 - name: run goreleaser
M Makefile +6 -6
@@ -66,13 +66,13 @@ clean:
6666 rm -rf "$(BIN_DIR)"
6767
6868 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 \
7070 echo "set VERSION to a release version, for example: make release VERSION=0.1.0" >&2; \
7171 exit 1; \
7272 fi
7373 @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 fi72 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/zot74+ 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/zot75+ 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/zot76+ 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/zot77+ 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/zot78+ 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
44 import (
55 "fmt"
66 "os"
7+ "regexp"
78 "runtime/debug"
89 "strings"
910
@@ -59,23 +60,8 @@ func resolvedVersionFromBuildInfo(linkedVersion string, info *debug.BuildInfo) s
5960 return strings.TrimPrefix(info.Main.Version, "v")
6061 }
6162
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+
6265 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)
8167 }
@@ -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 (
55 "testing"
66 )
77
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+
831 func TestResolvedVersionFromBuildInfo(t *testing.T) {
932 tests := []struct {
1033 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 string33 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
8484 # install a Go example and explicitly build it in the staged install
8585 zot ext install --build=go path/to/zot/examples/extensions/hello
8686
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 .
8889 zot --ext path/to/zot/examples/extensions/hello
8990 ```
9091
@@ -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 install84 # install a Go example and explicitly build it in the staged install
85 zot ext install --build=go path/to/zot/examples/extensions/hello85 zot ext install --build=go path/to/zot/examples/extensions/hello
86 86
87-# or load straight from the repo for one zot session87+# 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/hello89 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) {
423423 )
424424 section("extensions",
425425 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"},
427428 row{"zot --ext ./path/to/ext", "load an extension for this run only"},
428429 row{"zot ext help", "show all extension subcommands"},
429430 )
@@ -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 {
9292 continue
9393 }
9494 for _, e := range entries {
95- if !e.IsDir() {
95+ if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
9696 continue
9797 }
9898 extDir := filepath.Join(dir, e.Name())
@@ -223,7 +223,7 @@ func scanExtDoctorStatic() []extDoctorStaticRow {
223223 }
224224 sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
225225 for _, e := range entries {
226- if !e.IsDir() {
226+ if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
227227 continue
228228 }
229229 extDir := filepath.Join(sd.dir, e.Name())
@@ -523,7 +523,7 @@ func extInstall(args []string) error {
523523 return fmt.Errorf("copy extension: %w", err)
524524 }
525525 if opts.builder != "" {
526- if err := buildLocalExtension(opts.builder, absSrc, staged, manifest); err != nil {
526+ if err := buildLocalExtension(absSrc, staged, manifest); err != nil {
527527 return err
528528 }
529529 }
@@ -591,14 +591,10 @@ func parseExtInstallArgs(args []string) (extInstallOptions, error) {
591591 return opts, nil
592592 }
593593
594-func buildLocalExtension(builder, sourceDir, stagedDir string, manifest extensions.Manifest) error {
594+func buildLocalExtension(sourceDir, stagedDir string, manifest extensions.Manifest) error {
595595 if manifest.Exec == "" {
596596 return errors.New("cannot build a theme-only extension; it does not declare an executable")
597597 }
598- if builder != "go" {
599- return fmt.Errorf("unsupported extension builder %q (currently supported: go)", builder)
600- }
601-
602598 execRel, err := extensionRelativeExecPath(sourceDir, manifest.Exec)
603599 if err != nil {
604600 return fmt.Errorf("%w: --build=go requires an extension-relative executable path", err)
@@ -685,6 +681,9 @@ func readExtensionManifest(dir string) (extensions.Manifest, error) {
685681 if strings.TrimSpace(manifest.Name) == "" {
686682 return extensions.Manifest{}, errors.New("manifest: name is required")
687683 }
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+ }
688687 if manifest.Name == "." || manifest.Name == ".." || strings.ContainsAny(manifest.Name, `/\\`) {
689688 return extensions.Manifest{}, fmt.Errorf("manifest: invalid extension name %q", manifest.Name)
690689 }
@@ -744,7 +743,7 @@ func validateExtensionExecutable(dir string, manifest extensions.Manifest) error
744743 return fmt.Errorf("extension executable %q is a directory", manifest.Exec)
745744 }
746745 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)
748747 }
749748 return nil
750749 }
@@ -92,7 +92,7 @@ func extList() error {
92 continue92 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 continue96 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 continue227 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 err527 return err
528 }528 }
529 }529 }
@@ -591,14 +591,10 @@ func parseExtInstallArgs(args []string) (extInstallOptions, error) {
591 return opts, nil591 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 nil748 return nil
750 }749 }
M packages/agent/extcmd_test.go +18 -0
@@ -215,6 +215,7 @@ func TestParseExtInstallArgs(t *testing.T) {
215215 {name: "equals", args: []string{"--build=go", "./ext"}, builder: "go"},
216216 {name: "separate", args: []string{"./ext", "--build", "go"}, builder: "go"},
217217 {name: "missing builder", args: []string{"--build=", "./ext"}, wantErr: "requires a builder"},
218+ {name: "missing separate builder", args: []string{"./ext", "--build"}, wantErr: "requires a builder"},
218219 {name: "missing source", args: []string{"--build=go"}, wantErr: "usage:"},
219220 {name: "extra source", args: []string{"./one", "./two"}, wantErr: "usage:"},
220221 {name: "unknown option", args: []string{"--wat", "./ext"}, wantErr: "unknown ext install option"},
@@ -260,6 +261,23 @@ func TestExtInstallRejectsBuildForThemeOnly(t *testing.T) {
260261 }
261262 }
262263
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+
263281 func TestExtInstallRejectsBuildForPATHExecutable(t *testing.T) {
264282 home := t.TempDir()
265283 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 {
279279 continue // missing directory is fine
280280 }
281281 for _, e := range entries {
282- if !e.IsDir() {
282+ if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
283283 continue
284284 }
285285 if seenDirs[e.Name()] {
@@ -279,7 +279,7 @@ func (m *Manager) Discover(ctx context.Context) []error {
279 continue // missing directory is fine279 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 continue283 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
22
33 import (
44 "context"
5+ "errors"
6+ "net/http"
57 "os"
68 "path/filepath"
79 "testing"
@@ -20,8 +22,22 @@ func TestIsDevVersion(t *testing.T) {
2022 }
2123 }
2224
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+
2334 func TestCheckForUpdateSkipsDevVersionWithoutCacheOrNetwork(t *testing.T) {
2435 home := t.TempDir()
36+ transport := &countingRoundTripper{}
37+ previousTransport := http.DefaultTransport
38+ http.DefaultTransport = transport
39+ t.Cleanup(func() { http.DefaultTransport = previousTransport })
40+
2541 ctx, cancel := context.WithCancel(context.Background())
2642 cancel()
2743
@@ -32,4 +48,7 @@ func TestCheckForUpdateSkipsDevVersionWithoutCacheOrNetwork(t *testing.T) {
3248 if _, err := os.Stat(filepath.Join(home, updateCheckFile)); !os.IsNotExist(err) {
3349 t.Fatalf("development check wrote a cache file: %v", err)
3450 }
51+ if transport.calls != 0 {
52+ t.Fatalf("development check made %d network requests, want 0", transport.calls)
53+ }
3554 }
@@ -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 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close