1package agent
2
3import (
4 "archive/tar"
5 "bytes"
6 "compress/gzip"
7 "net/http"
8 "net/http/httptest"
9 "net/url"
10 "os"
11 "path/filepath"
12 "strings"
13 "testing"
14
15 "github.com/patriceckhart/zot/packages/agent/tools"
16)
17
18func writeTestZotfile(t *testing.T, manifest string) string {
19 t.Helper()
20 dir := t.TempDir()
21 if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0o600); err != nil {
22 t.Fatal(err)
23 }
24 if err := os.WriteFile(filepath.Join(dir, "AGENT.md"), []byte("Be useful."), 0o600); err != nil {
25 t.Fatal(err)
26 }
27 return dir
28}
29
30func TestLoadZotfileRejectsUnenforcedPermissions(t *testing.T) {
31 for _, field := range []string{
32 `"net":{"allow":["example.com"]}`,
33 `"env":{"read":["HOME"]}`,
34 } {
35 dir := writeTestZotfile(t, `{"zotfile":1,"name":"test","permissions":{`+field+`}}`)
36 if _, _, err := loadZotfile(dir); err == nil {
37 t.Fatalf("manifest with %s was accepted", field)
38 }
39 }
40}
41
42func TestLoadZotfileRejectsUnsafeOrCollidingNames(t *testing.T) {
43 for _, name := range []string{"...", "Name", "two words", "a/b"} {
44 dir := writeTestZotfile(t, `{"zotfile":1,"name":"`+name+`"}`)
45 if _, _, err := loadZotfile(dir); err == nil {
46 t.Fatalf("unsafe manifest name %q was accepted", name)
47 }
48 }
49}
50
51func TestLoadZotfileRejectsBundledExecutableExtension(t *testing.T) {
52 dir := writeTestZotfile(t, `{"zotfile":1,"name":"test"}`)
53 ext := filepath.Join(dir, "extensions", "bad")
54 if err := os.MkdirAll(ext, 0o700); err != nil {
55 t.Fatal(err)
56 }
57 if err := os.WriteFile(filepath.Join(ext, "extension.json"), []byte(`{"name":"bad"}`), 0o600); err != nil {
58 t.Fatal(err)
59 }
60 if _, _, err := loadZotfile(dir); err == nil || !strings.Contains(err.Error(), "cannot yet be confined") {
61 t.Fatalf("unexpected error: %v", err)
62 }
63}
64
65func TestCheckZotfileMinVersion(t *testing.T) {
66 var zf zotfileLoaded
67 zf.Manifest.Runtime.MinZot = "0.3.0"
68 if err := checkZotfileRequirements(zf, "0.2.75"); err == nil {
69 t.Fatal("old zot version accepted")
70 }
71 if err := checkZotfileRequirements(zf, "0.3.0"); err != nil {
72 t.Fatalf("minimum version rejected: %v", err)
73 }
74}
75
76func TestApplyZotfileModelRequirementsRejectsUnsupportedFields(t *testing.T) {
77 var m ZotfileManifest
78 m.Model.MinTier = "frontier"
79 if err := applyZotfileModelRequirements(&Args{}, m); err == nil {
80 t.Fatal("unsupported min_tier was ignored")
81 }
82 m.Model.MinTier = ""
83 m.Model.Requires = []string{"audio"}
84 if err := applyZotfileModelRequirements(&Args{}, m); err == nil {
85 t.Fatal("unsupported capability was ignored")
86 }
87}
88
89func TestUntarRejectsTraversalAndOversizedEntry(t *testing.T) {
90 makeTar := func(name string, size int64) []byte {
91 var buf bytes.Buffer
92 tw := tar.NewWriter(&buf)
93 if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: size}); err != nil {
94 t.Fatal(err)
95 }
96 _ = tw.Close()
97 return buf.Bytes()
98 }
99 if err := untar(bytes.NewReader(makeTar("../escape", 0)), t.TempDir()); err == nil {
100 t.Fatal("path traversal accepted")
101 }
102 if err := untar(bytes.NewReader(makeTar("large", maxZotfileEntrySize+1)), t.TempDir()); err == nil {
103 t.Fatal("oversized entry accepted")
104 }
105}
106
107func TestParseGitHubAgentURL(t *testing.T) {
108 tests := []struct {
109 input string
110 owner, repo, ref, dir string
111 }{
112 {"https://github.com/patriceckhart/agents/zot-maintenance", "patriceckhart", "agents", "HEAD", "zot-maintenance"},
113 {"https://github.com/patriceckhart/agents/tree/main/zot-maintenance", "patriceckhart", "agents", "main", "zot-maintenance"},
114 {"https://github.com/acme/agent.git", "acme", "agent", "HEAD", ""},
115 }
116 for _, tt := range tests {
117 u, err := url.Parse(tt.input)
118 if err != nil {
119 t.Fatal(err)
120 }
121 owner, repo, ref, dir, err := parseGitHubAgentURL(u)
122 if err != nil {
123 t.Fatalf("parse %s: %v", tt.input, err)
124 }
125 if owner != tt.owner || repo != tt.repo || ref != tt.ref || dir != tt.dir {
126 t.Fatalf("parse %s = %q, %q, %q, %q", tt.input, owner, repo, ref, dir)
127 }
128 }
129}
130
131func TestLoadRemoteZotfileDownloadsTemporaryGitHubArchive(t *testing.T) {
132 var archive bytes.Buffer
133 gz := gzip.NewWriter(&archive)
134 tw := tar.NewWriter(gz)
135 files := map[string]string{
136 "agents-main/zot-maintenance/manifest.json": `{"zotfile":1,"name":"zot-maintenance"}`,
137 "agents-main/zot-maintenance/AGENT.md": "Maintain zot.",
138 }
139 for name, content := range files {
140 if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(content))}); err != nil {
141 t.Fatal(err)
142 }
143 if _, err := tw.Write([]byte(content)); err != nil {
144 t.Fatal(err)
145 }
146 }
147 if err := tw.Close(); err != nil {
148 t.Fatal(err)
149 }
150 if err := gz.Close(); err != nil {
151 t.Fatal(err)
152 }
153
154 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
155 w.Header().Set("Content-Type", "application/gzip")
156 _, _ = w.Write(archive.Bytes())
157 }))
158 defer server.Close()
159 oldArchiveURL := githubArchiveURL
160 githubArchiveURL = func(_, _, _ string) string { return server.URL }
161 t.Cleanup(func() { githubArchiveURL = oldArchiveURL })
162
163 u, _ := url.Parse("https://github.com/patriceckhart/agents/zot-maintenance")
164 zf, cleanup, err := loadRemoteZotfile(u)
165 if err != nil {
166 t.Fatal(err)
167 }
168 if zf.Manifest.Name != "zot-maintenance" || !zf.Temp {
169 t.Fatalf("unexpected zotfile: %+v", zf)
170 }
171 root := filepath.Dir(filepath.Dir(zf.Dir))
172 cleanup()
173 if _, err := os.Stat(root); !os.IsNotExist(err) {
174 t.Fatalf("temporary checkout was not removed: %v", err)
175 }
176}
177
178func TestPermissionSummaryShowsDeniedScopes(t *testing.T) {
179 got := permissionSummary(tools.PermissionSet{})
180 if !strings.Contains(got, "fs read: none") || !strings.Contains(got, "fs write: none") {
181 t.Fatalf("summary did not show denied scopes:\n%s", got)
182 }
183}