diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..ac3583c --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,23 @@ +name: security + +on: + schedule: + - cron: '17 6 * * 1' # weekly Monday ~06:17 UTC + workflow_dispatch: # manual trigger + +jobs: + govulncheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Run govulncheck + run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... + + dependency-review: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/dependency-review-action@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ce150e9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,18 @@ +name: test + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + unit-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Run unit tests + run: go test ./... -race -count=1 diff --git a/.planning/debug/buf-v2-content-type.md b/.planning/debug/buf-v2-content-type.md new file mode 100644 index 0000000..897715e --- /dev/null +++ b/.planning/debug/buf-v2-content-type.md @@ -0,0 +1,48 @@ +--- +status: resolved +trigger: "buf 1.69.0 working fine with v1 config, but reporting the error after config migrated to v2. Error: invalid content-type: text/plain; charset=utf-8; expecting application/proto. Proxy at buf-proxy-dev.yadro.dev returns text/plain instead of application/proto for CommitService/GetCommits." +created: 2026-05-29 +updated: 2026-05-29 +--- + +# Debug Session: buf-v2-content-type + +## Symptoms + +- **Expected behavior:** `buf dep update` succeeds with v2 buf.yaml config pointing at buf-proxy-dev.yadro.dev +- **Actual behavior:** `Failure: unknown: invalid content-type: "text/plain; charset=utf-8"; expecting "application/proto"` +- **Error messages:** `buf.registry.module.v1.CommitService/GetCommits` returns `status: unknown`, content-type is `text/plain; charset=utf-8` instead of `application/proto` +- **Timeline:** Worked with buf v1 config format. Broke immediately after migrating config to v2 format. +- **Reproduction:** Run `buf dep update --debug` in any proto module using v2 buf config with remote pointing at buf-proxy-dev.yadro.dev +- **Key clue:** `message.received.uncompressed_size: 0` -- empty response body from proxy + +## Current Focus + +- hypothesis: null +- test: null +- expecting: null +- next_action: null +- reasoning_checkpoint: null + +## Evidence + +- timestamp: 2026-05-29T00:01 + type: code_analysis + file: internal/connect/api.go:56-60 + finding: "Route registrations show CommitService/GraphService/DownloadService are only registered for v1beta1 paths. ModuleService has BOTH v1 and v1beta1. When buf v2 config is used, buf CLI calls v1 service paths (buf.registry.module.v1.CommitService) which are unhandled." +- timestamp: 2026-05-29T00:02 + type: code_analysis + file: internal/connect/api.go:27-30 + finding: "rootHandler returns 200 OK with text/plain body. Go net/http default content-type for string writes is text/plain; charset=utf-8. Unhandled /buf.registry.module.v1.CommitService/ path falls through to rootHandler." +- timestamp: 2026-05-29T00:03 + type: reasoning + finding: "When buf v1 config -> buf CLI uses v1beta1 paths -> hits registered handlers -> works. When buf v2 config -> buf CLI uses v1 paths -> ModuleService works (has v1 route) but CommitService/GraphService/DownloadService fall through to rootHandler -> text/plain response -> content-type mismatch error." + +## Eliminated + +## Resolution + +- root_cause: "Missing v1 service path routes for CommitService, GraphService, and DownloadService in api.go. Only v1beta1 paths are registered (lines 56-58). When buf CLI uses v2 config format, it calls v1 paths which fall through to the text/plain rootHandler." +- fix: "Add v1 route registrations for CommitService, GraphService, and DownloadService alongside the existing v1beta1 routes, mirroring the pattern already used for ModuleService (lines 59-60)." +- verification: "Run buf dep update with v2 config pointing at the proxy; confirm content-type is application/proto and request succeeds." +- files_changed: internal/connect/api.go diff --git a/internal/connect/api.go b/internal/connect/api.go index 09afedc..903c6bb 100644 --- a/internal/connect/api.go +++ b/internal/connect/api.go @@ -47,14 +47,19 @@ func New( mux.Handle(connect.NewRepositoryServiceHandler(a)) mux.Handle(connect.NewDownloadServiceHandler(a)) - // v1beta1 CommitService handler for modern buf CLI (v1.69.0+). + // CommitService/GraphService/DownloadService handlers for buf CLI v1.69.0+. + // Both v1 and v1beta1 paths are registered because buf CLI uses v1beta1 + // paths with v1 buf.yaml config and v1 paths with v2 buf.yaml config. commitHandler := &commitServiceHandler{ api: a, commitMap: make(map[string]moduleRef), infoCache: make(map[string]commitInfoCache), filesMap: make(map[string][]content.File), } + mux.HandleFunc("/buf.registry.module.v1.CommitService/", commitHandler.ServeHTTP) mux.HandleFunc("/buf.registry.module.v1beta1.CommitService/", commitHandler.ServeHTTP) + mux.HandleFunc("/buf.registry.module.v1.GraphService/", commitHandler.ServeGraph) mux.HandleFunc("/buf.registry.module.v1beta1.GraphService/", commitHandler.ServeGraph) + mux.HandleFunc("/buf.registry.module.v1.DownloadService/", commitHandler.ServeDownload) mux.HandleFunc("/buf.registry.module.v1beta1.DownloadService/", commitHandler.ServeDownload) mux.HandleFunc("/buf.registry.module.v1.ModuleService/", commitHandler.ServeGetModules) mux.HandleFunc("/buf.registry.module.v1beta1.ModuleService/", commitHandler.ServeGetModules) diff --git a/internal/connect/api_test.go b/internal/connect/api_test.go new file mode 100644 index 0000000..2f3b6e6 --- /dev/null +++ b/internal/connect/api_test.go @@ -0,0 +1,418 @@ +package connect + +import ( + "bytes" + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/easyp-tech/server/internal/providers/content" + "github.com/easyp-tech/server/internal/shake256" + "google.golang.org/protobuf/encoding/protowire" +) + +// mockProvider implements provider for testing. +type mockProvider struct { + meta content.Meta + files []content.File + err error +} + +func (m *mockProvider) GetMeta(_ context.Context, _, _, _ string) (content.Meta, error) { + return m.meta, m.err +} + +func (m *mockProvider) GetFiles(_ context.Context, _, _, _ string) ([]content.File, error) { + return m.files, m.err +} + +func testMux(p provider) *http.ServeMux { + return New(slog.Default(), p, "buf.example.com") +} + +// buildGetCommitsRequest builds a protobuf-encoded GetCommits request +// with one resource ref for the given owner/module. +// ResourceRef { Name name = 2; Name { owner = 1; module = 2 } } +func buildGetCommitsRequest(owner, module string) []byte { + // Name: owner=1, module=2 + var name []byte + name = protowire.AppendTag(name, 1, protowire.BytesType) + name = protowire.AppendString(name, owner) + name = protowire.AppendTag(name, 2, protowire.BytesType) + name = protowire.AppendString(name, module) + + // ResourceRef: name=2 + var ref []byte + ref = protowire.AppendTag(ref, 2, protowire.BytesType) + ref = append(ref, protowire.AppendVarint(nil, uint64(len(name)))...) + ref = append(ref, name...) + + // GetCommitsRequest: resource_refs=1 + var req []byte + req = protowire.AppendTag(req, 1, protowire.BytesType) + req = append(req, protowire.AppendVarint(nil, uint64(len(ref)))...) + req = append(req, ref...) + return req +} + +// buildGetGraphRequest builds a protobuf-encoded GetGraph request. +// GetGraphRequest { resource_refs = 1; GetGraphRequest_ResourceRef { resource_ref = 1; ResourceRef { name = 2; Name { owner=1; module=2 } } } } +func buildGetGraphRequest(owner, module string) []byte { + var name []byte + name = protowire.AppendTag(name, 1, protowire.BytesType) + name = protowire.AppendString(name, owner) + name = protowire.AppendTag(name, 2, protowire.BytesType) + name = protowire.AppendString(name, module) + + var resRef []byte + resRef = protowire.AppendTag(resRef, 2, protowire.BytesType) + resRef = append(resRef, protowire.AppendVarint(nil, uint64(len(name)))...) + resRef = append(resRef, name...) + + var graphRef []byte + graphRef = protowire.AppendTag(graphRef, 1, protowire.BytesType) + graphRef = append(graphRef, protowire.AppendVarint(nil, uint64(len(resRef)))...) + graphRef = append(graphRef, resRef...) + + var req []byte + req = protowire.AppendTag(req, 1, protowire.BytesType) + req = append(req, protowire.AppendVarint(nil, uint64(len(graphRef)))...) + req = append(req, graphRef...) + return req +} + +// buildDownloadRequest builds a protobuf-encoded Download request using a commit ID. +func buildDownloadRequest(commitID string) []byte { + // ResourceRef: id=1 + var resRef []byte + resRef = protowire.AppendTag(resRef, 1, protowire.BytesType) + resRef = protowire.AppendString(resRef, commitID) + + // DownloadRequest_ResourceRef: resource_ref=1 + var wrapper []byte + wrapper = protowire.AppendTag(wrapper, 1, protowire.BytesType) + wrapper = append(wrapper, protowire.AppendVarint(nil, uint64(len(resRef)))...) + wrapper = append(wrapper, resRef...) + + // DownloadRequest: resource_ref=1 + var req []byte + req = protowire.AppendTag(req, 1, protowire.BytesType) + req = append(req, protowire.AppendVarint(nil, uint64(len(wrapper)))...) + req = append(req, wrapper...) + return req +} + +// --- Route registration tests --- + +func TestV1RoutesRegistered(t *testing.T) { + p := &mockProvider{} + mux := testMux(p) + server := httptest.NewServer(mux) + defer server.Close() + + paths := []struct { + name string + path string + }{ + {"CommitService v1", "/buf.registry.module.v1.CommitService/GetCommits"}, + {"CommitService v1beta1", "/buf.registry.module.v1beta1.CommitService/GetCommits"}, + {"GraphService v1", "/buf.registry.module.v1.GraphService/GetGraph"}, + {"GraphService v1beta1", "/buf.registry.module.v1beta1.GraphService/GetGraph"}, + {"DownloadService v1", "/buf.registry.module.v1.DownloadService/Download"}, + {"DownloadService v1beta1", "/buf.registry.module.v1beta1.DownloadService/Download"}, + {"ModuleService v1", "/buf.registry.module.v1.ModuleService/GetModules"}, + {"ModuleService v1beta1", "/buf.registry.module.v1beta1.ModuleService/GetModules"}, + } + + for _, tc := range paths { + t.Run(tc.name, func(t *testing.T) { + // POST with empty body — handler should return 400, not fall through + // to rootHandler (which returns 200 text/plain). + // Any non-200 or a 200 with application/proto means the route is registered. + resp, err := http.Post(server.URL+tc.path, "application/proto", bytes.NewReader(nil)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + ct := resp.Header.Get("Content-Type") + if resp.StatusCode == http.StatusOK && ct == "text/plain; charset=utf-8" { + t.Errorf("path %s not registered — fell through to rootHandler (200 text/plain)", tc.path) + } + }) + } +} + +func TestV1RoutesNotReachingRootHandler(t *testing.T) { + p := &mockProvider{ + meta: content.Meta{ + Commit: "abc123", + DefaultBranch: "main", + }, + files: []content.File{ + {Path: "a.proto", Data: []byte("syntax = \"proto3\";"), Hash: shake256.Hash{}}, + }, + } + mux := testMux(p) + server := httptest.NewServer(mux) + defer server.Close() + + v1Paths := []struct { + name string + path string + body []byte + }{ + {"CommitService v1", "/buf.registry.module.v1.CommitService/GetCommits", buildGetCommitsRequest("owner", "repo")}, + {"GraphService v1", "/buf.registry.module.v1.GraphService/GetGraph", buildGetGraphRequest("owner", "repo")}, + } + + for _, tc := range v1Paths { + t.Run(tc.name, func(t *testing.T) { + resp, err := http.Post(server.URL+tc.path, "application/proto", bytes.NewReader(tc.body)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + ct := resp.Header.Get("Content-Type") + if ct != "application/proto" { + body, _ := io.ReadAll(resp.Body) + t.Errorf("expected Content-Type application/proto, got %q; body: %s", ct, body) + } + }) + } +} + +// --- Handler content-type tests --- + +func TestCommitServiceV1ReturnsProtobuf(t *testing.T) { + p := &mockProvider{ + meta: content.Meta{ + Commit: "deadbeef", + DefaultBranch: "main", + }, + files: []content.File{ + {Path: "test.proto", Data: []byte("syntax = \"proto3\";"), Hash: shake256.Hash{}}, + }, + } + mux := testMux(p) + server := httptest.NewServer(mux) + defer server.Close() + + for _, path := range []string{ + "/buf.registry.module.v1.CommitService/GetCommits", + "/buf.registry.module.v1beta1.CommitService/GetCommits", + } { + t.Run(path, func(t *testing.T) { + body := buildGetCommitsRequest("owner", "repo") + resp, err := http.Post(server.URL+path, "application/proto", bytes.NewReader(body)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if ct := resp.Header.Get("Content-Type"); ct != "application/proto" { + t.Errorf("Content-Type = %q, want %q", ct, "application/proto") + } + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 200; body: %s", resp.StatusCode, respBody) + } + + respBody, _ := io.ReadAll(resp.Body) + if len(respBody) == 0 { + t.Fatal("empty response body") + } + // Verify it's valid protobuf: should start with field tag + _, _, n := protowire.ConsumeTag(respBody) + if n < 0 { + t.Fatalf("response is not valid protobuf: %x", respBody[:min(len(respBody), 32)]) + } + }) + } +} + +func TestGraphServiceV1ReturnsProtobuf(t *testing.T) { + p := &mockProvider{ + meta: content.Meta{ + Commit: "cafe1234", + DefaultBranch: "main", + }, + files: []content.File{ + {Path: "graph.proto", Data: []byte("syntax = \"proto3\";"), Hash: shake256.Hash{}}, + }, + } + mux := testMux(p) + server := httptest.NewServer(mux) + defer server.Close() + + // Pre-populate commit cache via CommitService so GraphService can look it up. + commitResp, err := http.Post( + server.URL+"/buf.registry.module.v1.CommitService/GetCommits", + "application/proto", + bytes.NewReader(buildGetCommitsRequest("owner", "repo")), + ) + if err != nil { + t.Fatalf("pre-seed CommitService request failed: %v", err) + } + io.ReadAll(commitResp.Body) + commitResp.Body.Close() + + for _, path := range []string{ + "/buf.registry.module.v1.GraphService/GetGraph", + "/buf.registry.module.v1beta1.GraphService/GetGraph", + } { + t.Run(path, func(t *testing.T) { + body := buildGetGraphRequest("owner", "repo") + resp, err := http.Post(server.URL+path, "application/proto", bytes.NewReader(body)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if ct := resp.Header.Get("Content-Type"); ct != "application/proto" { + t.Errorf("Content-Type = %q, want %q", ct, "application/proto") + } + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 200; body: %s", resp.StatusCode, respBody) + } + + respBody, _ := io.ReadAll(resp.Body) + if len(respBody) == 0 { + t.Fatal("empty response body") + } + }) + } +} + +func TestDownloadServiceV1ReturnsProtobuf(t *testing.T) { + p := &mockProvider{ + meta: content.Meta{ + Commit: "f00dcafe", + DefaultBranch: "main", + }, + files: []content.File{ + {Path: "dl.proto", Data: []byte("syntax = \"proto3\";"), Hash: shake256.Hash{}}, + }, + } + mux := testMux(p) + server := httptest.NewServer(mux) + defer server.Close() + + // Pre-populate commit cache. + commitResp, err := http.Post( + server.URL+"/buf.registry.module.v1.CommitService/GetCommits", + "application/proto", + bytes.NewReader(buildGetCommitsRequest("owner", "repo")), + ) + if err != nil { + t.Fatalf("pre-seed CommitService request failed: %v", err) + } + commitBody, _ := io.ReadAll(commitResp.Body) + commitResp.Body.Close() + + // Extract commit ID from CommitService response: field 1 (repeated), sub-field 1 (string id). + commitID := extractCommitID(commitBody) + if commitID == "" { + t.Fatal("failed to extract commit ID from CommitService response") + } + + for _, path := range []string{ + "/buf.registry.module.v1.DownloadService/Download", + "/buf.registry.module.v1beta1.DownloadService/Download", + } { + t.Run(path, func(t *testing.T) { + body := buildDownloadRequest(commitID) + resp, err := http.Post(server.URL+path, "application/proto", bytes.NewReader(body)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if ct := resp.Header.Get("Content-Type"); ct != "application/proto" { + t.Errorf("Content-Type = %q, want %q", ct, "application/proto") + } + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 200; body: %s", resp.StatusCode, respBody) + } + + respBody, _ := io.ReadAll(resp.Body) + if len(respBody) == 0 { + t.Fatal("empty response body") + } + }) + } +} + +func TestMethodNotAllowed(t *testing.T) { + p := &mockProvider{} + mux := testMux(p) + server := httptest.NewServer(mux) + defer server.Close() + + paths := []string{ + "/buf.registry.module.v1.CommitService/GetCommits", + "/buf.registry.module.v1.GraphService/GetGraph", + "/buf.registry.module.v1.DownloadService/Download", + "/buf.registry.module.v1.ModuleService/GetModules", + } + + for _, path := range paths { + t.Run(path, func(t *testing.T) { + resp, err := http.Get(server.URL + path) + if err != nil { + t.Fatalf("GET request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("GET %s: status = %d, want %d", path, resp.StatusCode, http.StatusMethodNotAllowed) + } + }) + } +} + +// extractCommitID extracts the first commit ID from a GetCommits response. +// Response: repeated { id=1, ... } +func extractCommitID(msg []byte) string { + for len(msg) > 0 { + num, typ, n := protowire.ConsumeTag(msg) + if n < 0 { + break + } + msg = msg[n:] + if num == 1 && typ == protowire.BytesType { + commit, mLen := protowire.ConsumeBytes(msg) + msg = msg[mLen:] + // Inside Commit: field 1 = id (string) + for len(commit) > 0 { + cNum, cTyp, cN := protowire.ConsumeTag(commit) + if cN < 0 { + break + } + commit = commit[cN:] + if cNum == 1 && cTyp == protowire.BytesType { + id, _ := protowire.ConsumeBytes(commit) + return string(id) + } + cN = protowire.ConsumeFieldValue(cNum, cTyp, commit) + if cN < 0 { + break + } + commit = commit[cN:] + } + } else { + n = protowire.ConsumeFieldValue(num, typ, msg) + if n < 0 { + break + } + msg = msg[n:] + } + } + return "" +}