diff --git a/workspace-server/internal/plugins/gitea.go b/workspace-server/internal/plugins/gitea.go index 3b8eb3c0c..076986f4e 100644 --- a/workspace-server/internal/plugins/gitea.go +++ b/workspace-server/internal/plugins/gitea.go @@ -1,21 +1,26 @@ package plugins import ( + "archive/tar" + "compress/gzip" "context" + "encoding/json" "fmt" + "io" + "net/http" "net/url" "os" "path/filepath" "regexp" "strings" + "time" ) -// GiteaResolver fetches plugins from a (typically private) Gitea repository -// by shallow-cloning at the specified ref and extracting an optional -// subpath. It exists so a declared plugin can resolve to a *subdirectory* -// of a larger repo — e.g. the `agent-skills/seo-all/` skill package inside -// the private seo-agent template repo — which the GitHub resolver cannot do -// (it copies the whole repo root). +// GiteaResolver fetches plugins from a (typically private) Gitea repository. +// It uses the Gitea archive API for HTTP(S) remotes and falls back to a +// shallow git clone only for local file:// test repos. The archive path is +// fast-fail: private or missing repos return a clear error within seconds +// instead of hanging on a credential prompt. // // Source-contract string (the value a template puts in `plugins:`): // @@ -34,15 +39,26 @@ import ( // Authentication: private Gitea repos need a PAT. The resolver reads the // token from the environment (MOLECULE_TEMPLATE_REPO_TOKEN by default — the // same read-only Gitea PAT CP PR#850 already places on every tenant box). -// The token is injected into the clone URL's userinfo and never logged. +// The token is sent in an Authorization header for API calls and is never +// logged or returned to clients. // // Pinned-ref enforcement mirrors GithubResolver: an unpinned spec (no // `#`) is rejected unless PLUGIN_ALLOW_UNPINNED=true. type GiteaResolver struct { - // GitRunner runs git commands. Defaults to defaultGitRunner (shells out - // to the system `git`). Overridable in tests. + // GitRunner runs git commands for file:// / local test remotes. + // Defaults to defaultGitRunner. Unused for HTTP(S) archive fetches. GitRunner func(ctx context.Context, dir string, args ...string) error + // ArchiveDownloader downloads and extracts a Gitea archive tarball for + // HTTP(S) remotes. Defaults to defaultArchiveDownloader. Overridable in + // tests to simulate private-repo 401/403/404 responses. + ArchiveDownloader func(ctx context.Context, archiveURL, token, dstDir string) error + + // ResolveRefClient optionally overrides the HTTP client used by + // ResolveRef to fetch the commit SHA via the Gitea API. Defaults to + // http.DefaultClient. Overridable in tests. + ResolveRefClient *http.Client + // BaseURL is the Gitea instance origin, e.g. "https://git.moleculesai.app". // Tests point it at a local file:// bare repo (in which case TokenEnv is // ignored — file:// has no userinfo auth). @@ -51,9 +67,13 @@ type GiteaResolver struct { // TokenEnv is the environment variable the PAT is read from at Fetch // time. Read lazily (not at construction) so a token rotated into the // process env after startup is picked up. Empty disables auth injection - // (anonymous clone — works for public repos and file:// test repos). + // (anonymous API calls — works for public repos; fails fast on private). TokenEnv string + // FetchTimeout bounds the archive download + SHA resolution for HTTP(S) + // remotes. Defaults to 30 seconds. Overridable in tests. + FetchTimeout time.Duration + // LastFetchSHA holds the commit SHA checked out by the last successful // Fetch. Mirrors GithubResolver so the install pipeline's drift-seed // type-switch can pick it up. Reset on each Fetch. @@ -68,9 +88,10 @@ func NewGiteaResolver() *GiteaResolver { base = "https://git.moleculesai.app" } return &GiteaResolver{ - GitRunner: defaultGitRunner, - BaseURL: base, - TokenEnv: "MOLECULE_TEMPLATE_REPO_TOKEN", + GitRunner: defaultGitRunner, + ArchiveDownloader: defaultArchiveDownloader, + BaseURL: base, + TokenEnv: "MOLECULE_TEMPLATE_REPO_TOKEN", } } @@ -169,7 +190,130 @@ func (r *GiteaResolver) cloneURL(owner, repo string) (string, error) { return u.String(), nil } -// Fetch clones the repo at the pinned ref and copies the (optional) subpath +// archiveRef returns a ref string suitable for the Gitea archive API. +// It strips the "tag:" and "sha:" prefixes used in plugin specs. +func archiveRef(ref string) string { + switch { + case strings.HasPrefix(ref, "tag:"): + return strings.TrimPrefix(ref, "tag:") + case strings.HasPrefix(ref, "sha:"): + return strings.TrimPrefix(ref, "sha:") + } + return ref +} + +// defaultArchiveDownloader downloads a Gitea archive tarball to dstDir and +// extracts it. It is fail-closed and token-safe: the token is sent in the +// Authorization header, never logged, and never surfaced in error messages. +func defaultArchiveDownloader(ctx context.Context, archiveURL, token, dstDir string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, archiveURL, nil) + if err != nil { + return fmt.Errorf("gitea resolver: build archive request: %w", err) + } + if token != "" { + req.Header.Set("Authorization", "token "+token) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("gitea resolver: archive download timed out") + } + return fmt.Errorf("gitea resolver: archive download failed: %w", err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + // proceed + case http.StatusNotFound: + return fmt.Errorf("gitea resolver: %s: %w", archiveURL, ErrPluginNotFound) + case http.StatusUnauthorized, http.StatusForbidden: + return fmt.Errorf("gitea resolver: %s: repository not accessible (HTTP %d)", archiveURL, resp.StatusCode) + default: + return fmt.Errorf("gitea resolver: %s: unexpected HTTP %d", archiveURL, resp.StatusCode) + } + + gr, err := gzip.NewReader(resp.Body) + if err != nil { + return fmt.Errorf("gitea resolver: gzip archive: %w", err) + } + defer gr.Close() + + tr := tar.NewReader(gr) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("gitea resolver: read tar archive: %w", err) + } + + // Clean the header name and reject traversal / absolute paths. + rel := filepath.Clean(hdr.Name) + if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { + continue + } + target := filepath.Join(dstDir, rel) + cleanTarget, err := filepath.Abs(target) + if err != nil { + return fmt.Errorf("gitea resolver: abs target: %w", err) + } + cleanDst, err := filepath.Abs(dstDir) + if err != nil { + return fmt.Errorf("gitea resolver: abs dst: %w", err) + } + if !strings.HasPrefix(cleanTarget, cleanDst+string(filepath.Separator)) { + continue // tar entry escapes extraction root — skip + } + + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, os.FileMode(hdr.Mode&0o777)); err != nil { + return fmt.Errorf("gitea resolver: mkdir %s: %w", target, err) + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return fmt.Errorf("gitea resolver: mkdir %s: %w", filepath.Dir(target), err) + } + f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode&0o777)) + if err != nil { + return fmt.Errorf("gitea resolver: create %s: %w", target, err) + } + if _, err := io.Copy(f, tr); err != nil { + f.Close() + return fmt.Errorf("gitea resolver: write %s: %w", target, err) + } + f.Close() + default: + // Skip symlinks, devices, etc. copyTree also skips symlinks. + } + } + return nil +} + +// repoRootFromArchive picks the single top-level directory inside an +// extracted Gitea archive. Gitea archives contain exactly one root directory +// named after the repo. +func repoRootFromArchive(archiveDir string) (string, error) { + entries, err := os.ReadDir(archiveDir) + if err != nil { + return "", fmt.Errorf("gitea resolver: read archive dir: %w", err) + } + var dirs []os.DirEntry + for _, e := range entries { + if e.IsDir() { + dirs = append(dirs, e) + } + } + if len(dirs) != 1 { + return "", fmt.Errorf("gitea resolver: expected exactly one root dir in archive, found %d", len(dirs)) + } + return filepath.Join(archiveDir, dirs[0].Name()), nil +} + +// Fetch resolves the repo at the pinned ref and copies the (optional) subpath // into dst. Returns the resolved plugin name. func (r *GiteaResolver) Fetch(ctx context.Context, spec string, dst string) (string, error) { p, err := parseGiteaSpec(spec) @@ -183,6 +327,22 @@ func (r *GiteaResolver) Fetch(ctx context.Context, spec string, dst string) (str spec, p.owner, p.repo) } + base := r.BaseURL + if base == "" { + base = "https://git.moleculesai.app" + } + + // Local file:// remotes (tests) keep the git-clone path. + if strings.HasPrefix(base, "file://") || strings.HasPrefix(base, "/") { + return r.fetchGit(ctx, p, dst) + } + + return r.fetchArchive(ctx, p, dst, base) +} + +// fetchGit is the legacy local-file:// path used only by tests. It preserves +// the original shallow-clone behavior so real-git test fixtures keep working. +func (r *GiteaResolver) fetchGit(ctx context.Context, p parsedGiteaSpec, dst string) (string, error) { runner := r.GitRunner if runner == nil { runner = defaultGitRunner @@ -206,11 +366,6 @@ func (r *GiteaResolver) Fetch(ctx context.Context, spec string, dst string) (str } args = append(args, "--", cloneURL, cloneTarget) if err := runner(ctx, workDir, args...); err != nil { - // Map "repo/ref doesn't exist" to ErrPluginNotFound (handler → 404). - // NOTE: the error string may contain the tokenized URL; callers MUST - // NOT surface resolver errors to clients (the install pipeline logs - // them server-side and returns a sanitized body). Errors are wrapped - // with a non-tokenized URL form for the log line. safeURL := fmt.Sprintf("%s/%s/%s.git", r.BaseURL, p.owner, p.repo) msg := strings.ToLower(err.Error()) if strings.Contains(msg, "repository not found") || @@ -222,17 +377,83 @@ func (r *GiteaResolver) Fetch(ctx context.Context, spec string, dst string) (str return "", fmt.Errorf("gitea resolver: clone %s failed: %w", safeURL, err) } - // Capture the installed SHA before stripping .git (drift-seed parity). if shaOut, shaErr := runGitOneLine(ctx, cloneTarget, "rev-parse", "--verify", "HEAD"); shaErr == nil { r.LastFetchSHA = strings.TrimSpace(shaOut) } - // The source tree to copy: repo root, or the subpath within it. + return r.stageTree(ctx, p, cloneTarget, dst) +} + +// fetchArchive downloads the repo via the authenticated Gitea archive API, +// extracts it, and stages the requested subpath. It is bounded by a strict +// timeout and maps 401/403/404 to clear, token-safe errors. +func (r *GiteaResolver) fetchArchive(ctx context.Context, p parsedGiteaSpec, dst, base string) (string, error) { + token := "" + if r.TokenEnv != "" { + token = strings.TrimSpace(os.Getenv(r.TokenEnv)) + } + + u, err := url.Parse(base) + if err != nil { + return "", fmt.Errorf("gitea resolver: invalid base url: %w", err) + } + ref := archiveRef(p.ref) + u.Path = fmt.Sprintf("/api/v1/repos/%s/%s/archive/%s.tar.gz", p.owner, p.repo, ref) + archiveURL := u.String() + + workDir, err := os.MkdirTemp("", "molecule-gitea-archive-*") + if err != nil { + return "", fmt.Errorf("gitea resolver: tempdir: %w", err) + } + defer os.RemoveAll(workDir) + + archiveDir := filepath.Join(workDir, "extracted") + if err := os.MkdirAll(archiveDir, 0o755); err != nil { + return "", fmt.Errorf("gitea resolver: mkdir archive dir: %w", err) + } + + downloader := r.ArchiveDownloader + if downloader == nil { + downloader = defaultArchiveDownloader + } + + // Bounded timeout: a private or unreachable repo must fail fast instead + // of hanging the install request until the gateway gives up (~100 s → 502). + timeout := r.FetchTimeout + if timeout <= 0 { + timeout = 30 * time.Second + } + fetchCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + if err := downloader(fetchCtx, archiveURL, token, archiveDir); err != nil { + return "", err + } + + cloneTarget, err := repoRootFromArchive(archiveDir) + if err != nil { + return "", err + } + + // Resolve the installed SHA via the Gitea API. Same timeout so a missing + // or private repo fails fast here too. + sha, err := r.resolveSHA(fetchCtx, p.owner, p.repo, ref, token, base) + if err != nil { + return "", err + } + if sha != "" { + r.LastFetchSHA = sha + } + + return r.stageTree(ctx, p, cloneTarget, dst) +} + +// stageTree copies the repo root (or subpath) into dst and derives the plugin +// name. Shared by fetchGit and fetchArchive. +func (r *GiteaResolver) stageTree(ctx context.Context, p parsedGiteaSpec, cloneTarget, dst string) (string, error) { srcTree := cloneTarget pluginName := p.repo if p.subpath != "" { - // filepath.Join cleans the path; reject any attempt to escape the - // clone (defence in depth — parseGiteaSpec already rejected ".."). joined := filepath.Join(cloneTarget, filepath.FromSlash(p.subpath)) relCheck, relErr := filepath.Rel(cloneTarget, joined) if relErr != nil || strings.HasPrefix(relCheck, "..") { @@ -250,11 +471,9 @@ func (r *GiteaResolver) Fetch(ctx context.Context, spec string, dst string) (str return "", fmt.Errorf("gitea resolver: subpath %q is not a directory", p.subpath) } srcTree = joined - // Plugin name is the last subpath segment. parts := strings.Split(p.subpath, "/") pluginName = parts[len(parts)-1] } else { - // Whole-repo install: strip .git so the plugin dir isn't a nested repo. if err := os.RemoveAll(filepath.Join(cloneTarget, ".git")); err != nil { return "", fmt.Errorf("gitea resolver: remove .git: %w", err) } @@ -267,6 +486,63 @@ func (r *GiteaResolver) Fetch(ctx context.Context, spec string, dst string) (str return pluginName, nil } +// resolveSHA fetches the commit SHA for a ref via the Gitea API. It is used +// by both Fetch (to populate LastFetchSHA) and ResolveRef. The ref argument +// must already be archiveRef-normalized. +func (r *GiteaResolver) resolveSHA(ctx context.Context, owner, repo, ref, token, base string) (string, error) { + u, err := url.Parse(base) + if err != nil { + return "", fmt.Errorf("gitea resolver: invalid base url: %w", err) + } + u.Path = fmt.Sprintf("/api/v1/repos/%s/%s/commits", owner, repo) + q := u.Query() + q.Set("sha", ref) + q.Set("limit", "1") + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return "", fmt.Errorf("gitea resolver: build commits request: %w", err) + } + if token != "" { + req.Header.Set("Authorization", "token "+token) + } + + client := r.ResolveRefClient + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return "", fmt.Errorf("gitea resolver: resolve SHA timed out") + } + return "", fmt.Errorf("gitea resolver: resolve SHA request failed: %w", err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + case http.StatusNotFound: + return "", fmt.Errorf("gitea resolver: %s/%s ref %q: %w", owner, repo, ref, ErrPluginNotFound) + case http.StatusUnauthorized, http.StatusForbidden: + return "", fmt.Errorf("gitea resolver: %s/%s ref %q: not accessible (HTTP %d)", owner, repo, ref, resp.StatusCode) + default: + return "", fmt.Errorf("gitea resolver: %s/%s ref %q: unexpected HTTP %d", owner, repo, ref, resp.StatusCode) + } + + var commits []struct { + SHA string `json:"sha"` + } + if err := json.NewDecoder(resp.Body).Decode(&commits); err != nil { + return "", fmt.Errorf("gitea resolver: decode commits: %w", err) + } + if len(commits) == 0 || commits[0].SHA == "" { + return "", fmt.Errorf("gitea resolver: %s/%s ref %q: no commit returned", owner, repo, ref) + } + return strings.TrimSpace(commits[0].SHA), nil +} + // ResolveRef resolves a gitea spec's ref to a full commit SHA, so the drift // sweeper can compare installed vs upstream for gitea:// sources too. // @@ -284,6 +560,30 @@ func (r *GiteaResolver) ResolveRef(ctx context.Context, spec string) (string, er return "", fmt.Errorf("gitea resolver: ResolveRef requires a ref (got bare %q)", spec) } + base := r.BaseURL + if base == "" { + base = "https://git.moleculesai.app" + } + ref := archiveRef(p.ref) + + // Local file:// remotes (tests) keep the git-fetch path. + if strings.HasPrefix(base, "file://") || strings.HasPrefix(base, "/") { + return r.resolveRefGit(ctx, p, ref) + } + + token := "" + if r.TokenEnv != "" { + token = strings.TrimSpace(os.Getenv(r.TokenEnv)) + } + + resolveCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + return r.resolveSHA(resolveCtx, p.owner, p.repo, ref, token, base) +} + +// resolveRefGit is the legacy local-file:// path used only by tests. +func (r *GiteaResolver) resolveRefGit(ctx context.Context, p parsedGiteaSpec, fetchRef string) (string, error) { runner := r.GitRunner if runner == nil { runner = defaultGitRunner @@ -299,16 +599,6 @@ func (r *GiteaResolver) ResolveRef(ctx context.Context, spec string) (string, er } defer os.RemoveAll(workDir) - // Normalize the ref into a fetchable git ref. - fetchRef := p.ref - switch { - case strings.HasPrefix(p.ref, "tag:"): - fetchRef = strings.TrimPrefix(p.ref, "tag:") - case strings.HasPrefix(p.ref, "sha:"): - fetchRef = strings.TrimPrefix(p.ref, "sha:") - } - - // `git -C init` then fetch the single ref shallowly. if err := runner(ctx, workDir, "init", "-q"); err != nil { return "", fmt.Errorf("gitea resolver: git init: %w", err) } diff --git a/workspace-server/internal/plugins/gitea_test.go b/workspace-server/internal/plugins/gitea_test.go index aba97db1f..44674f48d 100644 --- a/workspace-server/internal/plugins/gitea_test.go +++ b/workspace-server/internal/plugins/gitea_test.go @@ -1,13 +1,21 @@ package plugins import ( + "archive/tar" + "bytes" + "compress/gzip" "context" "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" + "time" ) func TestGiteaResolver_Scheme(t *testing.T) { @@ -316,3 +324,321 @@ func TestGiteaResolver_RegisteredScheme(t *testing.T) { t.Errorf("gitea scheme must resolve: %v", err) } } + +// makePluginTarball returns a gzip-compressed tar archive containing a repo +// root directory named after repo, with files under the given relPaths. +func makePluginTarball(t *testing.T, repo string, files map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + for rel, content := range files { + name := repo + "/" + rel + hdr := &tar.Header{ + Name: name, + Mode: 0o644, + Size: int64(len(content)), + Typeflag: tar.TypeReg, + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatalf("write header: %v", err) + } + if _, err := io.WriteString(tw, content); err != nil { + t.Fatalf("write body: %v", err) + } + } + if err := tw.Close(); err != nil { + t.Fatalf("close tar: %v", err) + } + if err := gw.Close(); err != nil { + t.Fatalf("close gzip: %v", err) + } + return buf.Bytes() +} + +// writeTarball writes a tarball to dstDir, simulating defaultArchiveDownloader. +func writeTarball(t *testing.T, dstDir string, data []byte) { + t.Helper() + gr, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + t.Fatalf("gzip reader: %v", err) + } + defer gr.Close() + tr := tar.NewReader(gr) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("tar next: %v", err) + } + target := filepath.Join(dstDir, hdr.Name) + if hdr.Typeflag == tar.TypeDir { + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatalf("mkdir parent: %v", err) + } + f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + t.Fatalf("create file: %v", err) + } + if _, err := io.Copy(f, tr); err != nil { + f.Close() + t.Fatalf("copy: %v", err) + } + f.Close() + } +} + +func TestGiteaResolver_ArchiveFetch_PrivateRepo_FastFail(t *testing.T) { + const tok = "SUPERSECRET-ARCHIVE-TOKEN-999" + t.Setenv("MOLECULE_TEMPLATE_REPO_TOKEN", tok) + + for _, code := range []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound} { + t.Run(fmt.Sprintf("HTTP%d", code), func(t *testing.T) { + r := &GiteaResolver{ + BaseURL: "https://git.example.com", + TokenEnv: "MOLECULE_TEMPLATE_REPO_TOKEN", + ArchiveDownloader: func(ctx context.Context, archiveURL, token, dstDir string) error { + if token != tok { + t.Errorf("token not passed to downloader: got %q", token) + } + switch code { + case http.StatusNotFound: + return fmt.Errorf("gitea resolver: %s: %w", archiveURL, ErrPluginNotFound) + default: + return fmt.Errorf("gitea resolver: %s: repository not accessible (HTTP %d)", archiveURL, code) + } + }, + } + + start := time.Now() + _, err := r.Fetch(context.Background(), "owner/repo#main", t.TempDir()) + if time.Since(start) > 2*time.Second { + t.Errorf("Fetch took too long (%s); should fast-fail", time.Since(start)) + } + if err == nil { + t.Fatal("expected error") + } + if strings.Contains(err.Error(), tok) { + t.Errorf("PAT leaked into error: %v", err) + } + if code == http.StatusNotFound { + if !errors.Is(err, ErrPluginNotFound) { + t.Errorf("expected ErrPluginNotFound for 404, got %v", err) + } + } else { + if errors.Is(err, ErrPluginNotFound) { + t.Errorf("did not expect ErrPluginNotFound for %d", code) + } + } + }) + } +} + +func TestGiteaResolver_ArchiveFetch_Timeout(t *testing.T) { + t.Setenv("MOLECULE_TEMPLATE_REPO_TOKEN", "tok") + r := &GiteaResolver{ + BaseURL: "https://git.example.com", + TokenEnv: "MOLECULE_TEMPLATE_REPO_TOKEN", + FetchTimeout: 500 * time.Millisecond, + ArchiveDownloader: func(ctx context.Context, archiveURL, token, dstDir string) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(5 * time.Minute): + return errors.New("should have been cancelled") + } + }, + } + + start := time.Now() + _, err := r.Fetch(context.Background(), "owner/repo#main", t.TempDir()) + if time.Since(start) > 2*time.Second { + t.Errorf("Fetch took too long (%s); timeout should fire around 500ms", time.Since(start)) + } + if err == nil { + t.Fatal("expected timeout error") + } + if !strings.Contains(err.Error(), "timed out") && !strings.Contains(err.Error(), "deadline exceeded") { + t.Errorf("expected timeout/deadline error, got %v", err) + } +} + +func TestGiteaResolver_ArchiveFetch_Success(t *testing.T) { + const tok = "SUPERSECRET-ARCHIVE-TOKEN-777" + t.Setenv("MOLECULE_TEMPLATE_REPO_TOKEN", tok) + + archive := makePluginTarball(t, "repo", map[string]string{ + "plugin.yaml": "name: repo\nversion: 1.0.0\n", + "README.md": "# repo", + }) + const wantSHA = "abc123def456abc123def456abc123def456abcd" + + commitsHandler := func(w http.ResponseWriter, req *http.Request) { + if auth := req.Header.Get("Authorization"); auth != "token "+tok { + t.Errorf("commits request auth header = %q, want token %s", auth, tok) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `[{"sha":"%s"}]`, wantSHA) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if strings.HasSuffix(req.URL.Path, "/commits") { + commitsHandler(w, req) + return + } + http.NotFound(w, req) + })) + defer server.Close() + + r := &GiteaResolver{ + BaseURL: server.URL, + TokenEnv: "MOLECULE_TEMPLATE_REPO_TOKEN", + ResolveRefClient: server.Client(), + ArchiveDownloader: func(ctx context.Context, archiveURL, token, dstDir string) error { + if token != tok { + t.Errorf("token not passed to downloader: got %q", token) + } + writeTarball(t, dstDir, archive) + return nil + }, + } + + dst := t.TempDir() + name, err := r.Fetch(context.Background(), "owner/repo#main", dst) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + if name != "repo" { + t.Errorf("plugin name = %q, want repo", name) + } + for _, want := range []string{"plugin.yaml", "README.md"} { + if _, err := os.Stat(filepath.Join(dst, want)); err != nil { + t.Errorf("expected %q in dst: %v", want, err) + } + } + if r.LastSHA() != wantSHA { + t.Errorf("LastSHA = %q, want %q", r.LastSHA(), wantSHA) + } +} + +func TestGiteaResolver_ArchiveFetch_Subpath(t *testing.T) { + const wantSHA = "abc123def456abc123def456abc123def456abcd" + archive := makePluginTarball(t, "template", map[string]string{ + "agent-skills/seo-all/plugin.yaml": "name: seo-all\n", + "agent-skills/seo-all/SKILL.md": "# SEO", + "config.yaml": "name: template", + }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if strings.HasSuffix(req.URL.Path, "/commits") { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `[{"sha":"%s"}]`, wantSHA) + return + } + http.NotFound(w, req) + })) + defer server.Close() + + r := &GiteaResolver{ + BaseURL: server.URL, + ResolveRefClient: server.Client(), + ArchiveDownloader: func(ctx context.Context, archiveURL, token, dstDir string) error { + writeTarball(t, dstDir, archive) + return nil + }, + } + + dst := t.TempDir() + name, err := r.Fetch(context.Background(), "owner/template/agent-skills/seo-all#main", dst) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + if name != "seo-all" { + t.Errorf("plugin name = %q, want seo-all", name) + } + for _, want := range []string{"plugin.yaml", "SKILL.md"} { + if _, err := os.Stat(filepath.Join(dst, want)); err != nil { + t.Errorf("expected %q in dst: %v", want, err) + } + } + for _, notWant := range []string{"config.yaml", "agent-skills"} { + if _, err := os.Stat(filepath.Join(dst, notWant)); !os.IsNotExist(err) { + t.Errorf("subpath isolation violated: %q leaked", notWant) + } + } + if r.LastSHA() != wantSHA { + t.Errorf("LastSHA = %q, want %q", r.LastSHA(), wantSHA) + } +} + +func TestGiteaResolver_ArchiveFetch_ResolveRef(t *testing.T) { + const wantSHA = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if auth := req.Header.Get("Authorization"); auth != "token the-token" { + t.Errorf("auth = %q, want token the-token", auth) + } + if strings.HasSuffix(req.URL.Path, "/commits") { + q := req.URL.Query() + if got := q.Get("sha"); got != "main" { + t.Errorf("sha query = %q, want main", got) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `[{"sha":"%s"}]`, wantSHA) + return + } + http.NotFound(w, req) + })) + defer server.Close() + + t.Setenv("MOLECULE_TEMPLATE_REPO_TOKEN", "the-token") + r := &GiteaResolver{ + BaseURL: server.URL, + TokenEnv: "MOLECULE_TEMPLATE_REPO_TOKEN", + ResolveRefClient: server.Client(), + } + + sha, err := r.ResolveRef(context.Background(), "owner/repo#main") + if err != nil { + t.Fatalf("ResolveRef: %v", err) + } + if sha != wantSHA { + t.Errorf("ResolveRef = %q, want %q", sha, wantSHA) + } +} + +func TestGiteaResolver_ArchiveFetch_ResolveRef_TagPrefix(t *testing.T) { + const wantSHA = "1111111111111111111111111111111111111111" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if strings.HasSuffix(req.URL.Path, "/commits") { + q := req.URL.Query() + if got := q.Get("sha"); got != "v1.2.0" { + t.Errorf("sha query = %q, want v1.2.0", got) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `[{"sha":"%s"}]`, wantSHA) + return + } + http.NotFound(w, req) + })) + defer server.Close() + + r := &GiteaResolver{ + BaseURL: server.URL, + ResolveRefClient: server.Client(), + } + + sha, err := r.ResolveRef(context.Background(), "owner/repo#tag:v1.2.0") + if err != nil { + t.Fatalf("ResolveRef: %v", err) + } + if sha != wantSHA { + t.Errorf("ResolveRef = %q, want %q", sha, wantSHA) + } +}