From 49fe9cd0b53632eeb5ca4abd4292b850c5be140a Mon Sep 17 00:00:00 2001 From: Greg Allen Date: Fri, 3 Jul 2026 21:54:00 -0400 Subject: [PATCH] feat(repos): add manifest parser and validation for repos.yaml Signed-off-by: Greg Allen Signed-off-by: Claude Signed-off-by: Greg Allen --- internal/forge/fake.go | 10 +- internal/repos/manifest.go | 551 ++++++++++++++++ internal/repos/manifest_test.go | 1053 +++++++++++++++++++++++++++++++ 3 files changed, 1612 insertions(+), 2 deletions(-) create mode 100644 internal/repos/manifest.go create mode 100644 internal/repos/manifest_test.go diff --git a/internal/forge/fake.go b/internal/forge/fake.go index c75791e0b..13cb673db 100644 --- a/internal/forge/fake.go +++ b/internal/forge/fake.go @@ -111,6 +111,7 @@ type FakeClient struct { // Pre-populated data Repos []Repository + OrgRepos map[string][]Repository // per-org repos; when set, ListOrgRepos uses this instead of Repos FileContents map[string][]byte // key: "owner/repo/path" WorkflowRuns map[string]*WorkflowRun // key: "owner/repo/workflow" Workflows map[string]*Workflow // key: "owner/repo/workflow" @@ -229,7 +230,7 @@ func (f *FakeClient) err(method string) error { return f.Errors[method] } -func (f *FakeClient) ListOrgRepos(_ context.Context, _ string) ([]Repository, error) { +func (f *FakeClient) ListOrgRepos(_ context.Context, org string) ([]Repository, error) { f.mu.Lock() defer f.mu.Unlock() @@ -237,8 +238,13 @@ func (f *FakeClient) ListOrgRepos(_ context.Context, _ string) ([]Repository, er return nil, e } + source := f.Repos + if f.OrgRepos != nil { + source = f.OrgRepos[org] + } + var result []Repository - for _, r := range f.Repos { + for _, r := range source { if r.Archived || r.Fork || r.Private { continue } diff --git a/internal/repos/manifest.go b/internal/repos/manifest.go new file mode 100644 index 000000000..cec93a5f6 --- /dev/null +++ b/internal/repos/manifest.go @@ -0,0 +1,551 @@ +// Package repos implements parsing, validation, and resolution of the +// repos.yaml declarative manifest that drives multi-repo management +// (ADR 0057). +package repos + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/fullsend-ai/fullsend/internal/forge" + "github.com/fullsend-ai/fullsend/internal/netutil" + "gopkg.in/yaml.v3" +) + +const maxManifestBytes = 1 << 20 // 1 MB + +// Manifest is the top-level structure of a repos.yaml file. +type Manifest struct { + Version int `yaml:"version"` + Mint MintConfig `yaml:"mint"` + Defaults DefaultsConfig `yaml:"defaults"` + Repos []RepoEntry `yaml:"repos"` +} + +// MintConfig holds the mint service connection parameters. +type MintConfig struct { + URL string `yaml:"url"` + Project string `yaml:"project"` + Region string `yaml:"region"` +} + +// DefaultsConfig holds default field values applied to every repo +// unless overridden at the per-repo level. +type DefaultsConfig struct { + InferenceProject string `yaml:"inference_project"` + InferenceRegion string `yaml:"inference_region"` + FullsendRef string `yaml:"fullsend_ref"` + BaseHarness string `yaml:"base_harness"` + AllowedRemoteResources []string `yaml:"allowed_remote_resources"` +} + +// RepoEntry represents a single repo or glob pattern in the manifest. +// It supports two YAML forms: a plain string ("acme/repo") or an +// object with optional per-repo overrides. +type RepoEntry struct { + Repo string `yaml:"repo"` + InferenceProject NullableString `yaml:"inference_project,omitempty"` + InferenceRegion NullableString `yaml:"inference_region,omitempty"` + FullsendRef NullableString `yaml:"fullsend_ref,omitempty"` + BaseHarness NullableString `yaml:"base_harness,omitempty"` +} + +// UnmarshalYAML handles both string and mapping YAML forms. +// It manually walks the mapping node to correctly detect !!null +// values on NullableString fields, since yaml.v3's struct decoder +// skips calling UnmarshalYAML for null-tagged scalars. +func (r *RepoEntry) UnmarshalYAML(node *yaml.Node) error { + if node.Kind == yaml.ScalarNode { + *r = RepoEntry{Repo: node.Value} + return nil + } + if node.Kind != yaml.MappingNode { + return fmt.Errorf("expected scalar or mapping for repo entry, got kind %d", node.Kind) + } + *r = RepoEntry{} + for i := 0; i < len(node.Content)-1; i += 2 { + key := node.Content[i] + val := node.Content[i+1] + switch key.Value { + case "repo": + r.Repo = val.Value + case "inference_project": + if err := decodeNullable(val, &r.InferenceProject); err != nil { + return fmt.Errorf("decoding inference_project: %w", err) + } + case "inference_region": + if err := decodeNullable(val, &r.InferenceRegion); err != nil { + return fmt.Errorf("decoding inference_region: %w", err) + } + case "fullsend_ref": + if err := decodeNullable(val, &r.FullsendRef); err != nil { + return fmt.Errorf("decoding fullsend_ref: %w", err) + } + case "base_harness": + if err := decodeNullable(val, &r.BaseHarness); err != nil { + return fmt.Errorf("decoding base_harness: %w", err) + } + default: + return fmt.Errorf("unknown field %q in repo entry", key.Value) + } + } + return nil +} + +// decodeNullable decodes a YAML node into a NullableString, handling +// null nodes explicitly since yaml.v3 skips custom unmarshalers for +// !!null-tagged scalars. +func decodeNullable(node *yaml.Node, ns *NullableString) error { + if node.Tag == "!!null" { + ns.Set = true + ns.Null = true + ns.Value = "" + return nil + } + ns.Set = true + ns.Null = false + ns.Value = "" + return node.Decode(&ns.Value) +} + +// NullableString distinguishes three YAML states: omitted (zero value), +// explicit null (Set=true, Null=true), and an explicit string value +// (Set=true, Null=false, Value holds the string). This three-state +// design lets per-repo overrides explicitly clear a default with +// "field: null" rather than inheriting it. +// +// A fourth state — Set=true, Value="" (explicit empty string in YAML) +// — is treated as unset by resolveField and falls through to defaults. +// This matches the ADR spec: "Empty-string and zero-value overrides +// are treated as unset and fall through to defaults." +type NullableString struct { + Value string + Set bool + Null bool +} + +// UnmarshalYAML decodes a YAML scalar into a NullableString, treating +// the !!null tag as an explicit null. +func (n *NullableString) UnmarshalYAML(node *yaml.Node) error { + if node.Tag == "!!null" { + n.Set = true + n.Null = true + n.Value = "" + return nil + } + n.Set = true + n.Null = false + n.Value = "" + return node.Decode(&n.Value) +} + +// MarshalYAML serializes a NullableString back to YAML, preserving +// the null vs omitted distinction. +func (n NullableString) MarshalYAML() (interface{}, error) { + if !n.Set { + return nil, nil + } + if n.Null { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null"}, nil + } + return n.Value, nil +} + +// IsZero reports whether n was never set, used by the YAML encoder +// to honor the omitempty tag. +func (n NullableString) IsZero() bool { + return !n.Set +} + +// ResolvedRepo pairs an owner/repo with the manifest entry that +// matched it (either an explicit entry or a glob-generated one). +type ResolvedRepo struct { + Owner string + Repo string + Entry RepoEntry +} + +// ResolvedConfig is the fully resolved configuration for a single +// repository after merging per-repo overrides, manifest defaults, +// and built-in defaults. +type ResolvedConfig struct { + Owner string + Repo string + MintURL string + MintProject string + MintRegion string + InferenceProject string + InferenceRegion string + FullsendRef string + BaseHarness string + AllowedRemoteResources []string +} + +// LoadManifest reads and parses a repos.yaml manifest from a local +// file path or an HTTPS URL. Remote fetches enforce a 30-second +// timeout and a 1 MB response size limit. +func LoadManifest(ctx context.Context, pathOrURL string) (*Manifest, error) { + var data []byte + var err error + + if strings.HasPrefix(pathOrURL, "https://") { + data, err = fetchManifestURL(ctx, pathOrURL, false) + if err != nil { + return nil, err + } + } else if strings.HasPrefix(pathOrURL, "http://") { + return nil, fmt.Errorf("insecure http:// not supported; use https://") + } else { + // Path is caller-controlled; no sanitization is performed here. + // Callers must ensure the path is safe before passing it in. + info, err := os.Stat(pathOrURL) + if err != nil { + return nil, fmt.Errorf("reading manifest file %s: %w", pathOrURL, err) + } + if info.Size() > maxManifestBytes { + return nil, fmt.Errorf("manifest file %s exceeds maximum size of %d bytes", pathOrURL, maxManifestBytes) + } + data, err = os.ReadFile(pathOrURL) + if err != nil { + return nil, fmt.Errorf("reading manifest file %s: %w", pathOrURL, err) + } + } + + var m Manifest + if err := yaml.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parsing manifest YAML: %w", err) + } + return &m, nil +} + +// safeDialContext wraps a net.Dialer to reject connections to +// internal/reserved IP addresses (loopback, link-local, private, etc.). +func safeDialContext(d *net.Dialer, skipIPCheck bool) func(ctx context.Context, network, addr string) (net.Conn, error) { + return func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, fmt.Errorf("invalid address %q: %w", addr, err) + } + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + if len(ips) == 0 { + return nil, fmt.Errorf("no addresses found for %q", host) + } + if !skipIPCheck { + for _, ip := range ips { + if reason := netutil.CheckIP(ip.IP); reason != "" { + return nil, fmt.Errorf("resolved address %s is blocked: %s", ip.IP, reason) + } + } + } + return d.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port)) + } +} + +// fetchManifestURL retrieves manifest YAML from an HTTPS URL with +// timeout, size limit, SSRF protections, and redirect restrictions. +// skipIPCheck bypasses internal-IP validation for tests using httptest +// servers on localhost; production callers must pass false. +func fetchManifestURL(ctx context.Context, rawURL string, skipIPCheck bool) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + client := &http.Client{ + Transport: &http.Transport{ + Proxy: nil, // ignore environment proxy settings + DialContext: safeDialContext(&net.Dialer{ + Timeout: 10 * time.Second, + }, skipIPCheck), + }, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 3 { + return fmt.Errorf("exceeded redirect limit (3)") + } + if req.URL.Scheme != "https" { + return fmt.Errorf("redirect to non-HTTPS URL %s", req.URL) + } + return nil + }, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, fmt.Errorf("fetching manifest from %s: %w", rawURL, err) + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching manifest from %s: %w", rawURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetching manifest from %s: HTTP %d", rawURL, resp.StatusCode) + } + + limited := io.LimitReader(resp.Body, maxManifestBytes+1) + data, err := io.ReadAll(limited) + if err != nil { + return nil, fmt.Errorf("reading manifest body from %s: %w", rawURL, err) + } + if int64(len(data)) > maxManifestBytes { + return nil, fmt.Errorf("manifest from %s exceeds maximum size of %d bytes", rawURL, maxManifestBytes) + } + + return data, nil +} + +// Validate checks the manifest for structural correctness: +// - version must be 1 +// - mint.url must be a valid HTTPS URL +// - mint.project and mint.region must be non-empty +// - each repo entry must have a valid owner/repo or owner/glob format +// - glob characters are only allowed in the repo name, not the owner +// - no duplicate repo entries (before glob expansion) +// - glob patterns must be valid filepath.Match patterns +func (m *Manifest) Validate() error { + if m.Version != 1 { + return fmt.Errorf("unsupported manifest version %d (expected 1)", m.Version) + } + + // Validate mint config. + if m.Mint.URL == "" { + return fmt.Errorf("mint.url is required") + } + u, err := url.Parse(m.Mint.URL) + if err != nil || u.Scheme != "https" || u.Host == "" { + return fmt.Errorf("mint.url must be a valid HTTPS URL, got %q", m.Mint.URL) + } + if m.Mint.Project == "" { + return fmt.Errorf("mint.project is required") + } + if m.Mint.Region == "" { + return fmt.Errorf("mint.region is required") + } + + // Validate repo entries. + seen := make(map[string]bool, len(m.Repos)) + for i, entry := range m.Repos { + if entry.Repo == "" { + return fmt.Errorf("repos[%d]: repo field is required", i) + } + + parts := strings.SplitN(entry.Repo, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return fmt.Errorf("repos[%d]: %q must be in owner/repo format", i, entry.Repo) + } + + // Glob characters are only allowed in the repo segment, not the owner. + if strings.ContainsAny(parts[0], "*?[") { + return fmt.Errorf("repos[%d]: glob characters are not allowed in owner segment %q", i, parts[0]) + } + + // Validate glob patterns in the repo segment. + if strings.ContainsAny(parts[1], "*?[") { + if _, err := filepath.Match(parts[1], "test"); err != nil { + return fmt.Errorf("repos[%d]: invalid glob pattern %q: %w", i, entry.Repo, err) + } + } + + // Check for duplicates. + if seen[entry.Repo] { + return fmt.Errorf("repos[%d]: duplicate repo %q", i, entry.Repo) + } + seen[entry.Repo] = true + } + + return nil +} + +// ExpandGlobs resolves wildcard repo entries by listing org repos +// via the forge API (requires network access). Explicit entries always +// win over glob-matched entries. The returned list is deduplicated and +// sorted. +// +// ListOrgRepos excludes private, archived, and forked repositories. +// Private repos must be listed as explicit entries in the manifest +// until the forge interface is extended (see implementation plan). +func (m *Manifest) ExpandGlobs(ctx context.Context, client forge.Client) ([]ResolvedRepo, error) { + // First pass: separate explicit entries from glob patterns. + explicit := make(map[string]RepoEntry) + type globEntry struct { + org string + pattern string + entry RepoEntry + } + var globs []globEntry + + for _, entry := range m.Repos { + parts := strings.SplitN(entry.Repo, "/", 2) + org := parts[0] + name := parts[1] + + if strings.ContainsAny(name, "*?[") { + globs = append(globs, globEntry{org: org, pattern: name, entry: entry}) + } else { + explicit[entry.Repo] = entry + } + } + + // Second pass: expand globs. + resolved := make(map[string]ResolvedRepo) + + // Add explicit entries first (they take priority). + for fullName, entry := range explicit { + parts := strings.SplitN(fullName, "/", 2) + resolved[fullName] = ResolvedRepo{ + Owner: parts[0], + Repo: parts[1], + Entry: entry, + } + } + + // Expand each glob pattern. + orgRepoCache := make(map[string][]forge.Repository) + for _, g := range globs { + repos, ok := orgRepoCache[g.org] + if !ok { + var err error + repos, err = client.ListOrgRepos(ctx, g.org) + if err != nil { + return nil, fmt.Errorf("expanding glob %q: listing repos for org %q: %w", g.org+"/"+g.pattern, g.org, err) + } + orgRepoCache[g.org] = repos + } + + for _, repo := range repos { + matched, err := filepath.Match(g.pattern, repo.Name) + if err != nil { + return nil, fmt.Errorf("matching glob %q against %q: %w", g.pattern, repo.Name, err) + } + if !matched { + continue + } + + fullName := g.org + "/" + repo.Name + // Explicit entries win over glob matches. + if _, exists := explicit[fullName]; exists { + continue + } + // First glob match wins (if multiple globs match the same repo). + if _, exists := resolved[fullName]; exists { + continue + } + + // Create an entry for the glob-matched repo, inheriting the + // glob entry's overrides but replacing the repo field with the + // actual repo name. + entry := g.entry + entry.Repo = fullName + resolved[fullName] = ResolvedRepo{ + Owner: g.org, + Repo: repo.Name, + Entry: entry, + } + } + } + + // Collect and sort results. + result := make([]ResolvedRepo, 0, len(resolved)) + for _, rr := range resolved { + result = append(result, rr) + } + sort.Slice(result, func(i, j int) bool { + return result[i].Owner+"/"+result[i].Repo < result[j].Owner+"/"+result[j].Repo + }) + + return result, nil +} + +// ResolveConfig computes the fully merged configuration for the given +// owner/repo by looking up the entry in the manifest's repo list. +// The resolution order is: +// +// 1. Per-repo override (from RepoEntry) +// 2. Manifest defaults (from DefaultsConfig) +// 3. Built-in defaults (empty strings) +// +// An explicit null at any level stops the fallback chain, returning "". +// The second return value indicates whether the repo was found in the +// manifest's repo list. When false, the returned config uses only +// manifest defaults and built-in values. +// +// For repos matched via glob expansion, use ResolveConfigForEntry +// instead — this method only finds exact matches in the manifest's +// repo list and will not match glob patterns. +func (m *Manifest) ResolveConfig(owner, repo string) (ResolvedConfig, bool) { + fullName := owner + "/" + repo + + // Find the matching entry. + for _, e := range m.Repos { + if e.Repo == fullName { + return m.resolveWithEntry(owner, repo, e), true + } + } + + return m.resolveWithEntry(owner, repo, RepoEntry{}), false +} + +// ResolveConfigForEntry computes the fully merged configuration for +// the given owner/repo using the provided RepoEntry. Use this with +// entries returned by ExpandGlobs, which carry per-glob overrides +// that ResolveConfig cannot find by exact match. +func (m *Manifest) ResolveConfigForEntry(owner, repo string, entry RepoEntry) ResolvedConfig { + return m.resolveWithEntry(owner, repo, entry) +} + +func (m *Manifest) resolveWithEntry(owner, repo string, entry RepoEntry) ResolvedConfig { + return ResolvedConfig{ + Owner: owner, + Repo: repo, + MintURL: m.Mint.URL, + MintProject: m.Mint.Project, + MintRegion: m.Mint.Region, + InferenceProject: resolveField(entry.InferenceProject, m.Defaults.InferenceProject, ""), + InferenceRegion: resolveField(entry.InferenceRegion, m.Defaults.InferenceRegion, ""), + FullsendRef: resolveField(entry.FullsendRef, m.Defaults.FullsendRef, ""), + BaseHarness: resolveField(entry.BaseHarness, m.Defaults.BaseHarness, ""), + AllowedRemoteResources: m.Defaults.AllowedRemoteResources, + } +} + +// resolveField implements the three-level fallback chain for a +// NullableString field. An explicitly set empty string (Set=true, +// Value="") is treated as unset and falls through to the fallback, +// matching the ADR spec: "Empty-string and zero-value overrides are +// treated as unset and fall through to defaults." To explicitly clear +// a field, use YAML null instead of an empty string. +func resolveField(override NullableString, fallback string, builtinDefault string) string { + if !override.Set { + if fallback != "" { + return fallback + } + return builtinDefault + } + if override.Null { + return "" // explicit null stops fallback chain + } + if override.Value != "" { + return override.Value + } + if fallback != "" { + return fallback + } + return builtinDefault +} + +// Marshal serializes the manifest back to YAML. +func (m *Manifest) Marshal() ([]byte, error) { + return yaml.Marshal(m) +} diff --git a/internal/repos/manifest_test.go b/internal/repos/manifest_test.go new file mode 100644 index 000000000..18e15379b --- /dev/null +++ b/internal/repos/manifest_test.go @@ -0,0 +1,1053 @@ +package repos + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/fullsend-ai/fullsend/internal/forge" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// validManifest is shared across parse, validate, resolve, and +// round-trip tests that all need the same well-formed baseline. +const validManifest = ` +version: 1 +mint: + url: https://mint.example.com + project: my-project + region: us-central1 +defaults: + inference_project: default-inference + inference_region: us-east1 + fullsend_ref: main + base_harness: default-harness + allowed_remote_resources: + - resource-a + - resource-b +repos: + - acme/repo-one + - acme/repo-two +` + +func TestParseSimpleManifest(t *testing.T) { + var m Manifest + err := yaml.Unmarshal([]byte(validManifest), &m) + require.NoError(t, err) + + assert.Equal(t, 1, m.Version) + assert.Equal(t, "https://mint.example.com", m.Mint.URL) + assert.Equal(t, "my-project", m.Mint.Project) + assert.Equal(t, "us-central1", m.Mint.Region) + assert.Equal(t, "default-inference", m.Defaults.InferenceProject) + assert.Equal(t, "us-east1", m.Defaults.InferenceRegion) + assert.Equal(t, "main", m.Defaults.FullsendRef) + assert.Equal(t, "default-harness", m.Defaults.BaseHarness) + assert.Equal(t, []string{"resource-a", "resource-b"}, m.Defaults.AllowedRemoteResources) + require.Len(t, m.Repos, 2) + assert.Equal(t, "acme/repo-one", m.Repos[0].Repo) + assert.Equal(t, "acme/repo-two", m.Repos[1].Repo) +} + +func TestParseMixedStringAndObjectRepos(t *testing.T) { + input := ` +version: 1 +mint: + url: https://mint.example.com + project: p + region: r +repos: + - acme/simple + - repo: acme/custom + inference_project: custom-project + fullsend_ref: v2 + - acme/another-simple +` + var m Manifest + err := yaml.Unmarshal([]byte(input), &m) + require.NoError(t, err) + + require.Len(t, m.Repos, 3) + + assert.Equal(t, "acme/simple", m.Repos[0].Repo) + assert.False(t, m.Repos[0].InferenceProject.Set) + + assert.Equal(t, "acme/custom", m.Repos[1].Repo) + assert.True(t, m.Repos[1].InferenceProject.Set) + assert.Equal(t, "custom-project", m.Repos[1].InferenceProject.Value) + assert.True(t, m.Repos[1].FullsendRef.Set) + assert.Equal(t, "v2", m.Repos[1].FullsendRef.Value) + + assert.Equal(t, "acme/another-simple", m.Repos[2].Repo) +} + +func TestParseManifestWithGlobPatterns(t *testing.T) { + input := ` +version: 1 +mint: + url: https://mint.example.com + project: p + region: r +repos: + - acme/* + - repo: other-org/service-* + inference_project: special +` + var m Manifest + err := yaml.Unmarshal([]byte(input), &m) + require.NoError(t, err) + + require.Len(t, m.Repos, 2) + assert.Equal(t, "acme/*", m.Repos[0].Repo) + assert.Equal(t, "other-org/service-*", m.Repos[1].Repo) + assert.Equal(t, "special", m.Repos[1].InferenceProject.Value) +} + +func TestRepoEntryUnmarshalYAML_StringForm(t *testing.T) { + var entry RepoEntry + node := &yaml.Node{Kind: yaml.ScalarNode, Value: "acme/my-repo"} + err := entry.UnmarshalYAML(node) + require.NoError(t, err) + assert.Equal(t, "acme/my-repo", entry.Repo) + assert.False(t, entry.InferenceProject.Set) +} + +func TestRepoEntryUnmarshalYAML_ObjectForm(t *testing.T) { + input := ` +repo: acme/my-repo +inference_project: custom +fullsend_ref: v3 +` + var entry RepoEntry + err := yaml.Unmarshal([]byte(input), &entry) + require.NoError(t, err) + assert.Equal(t, "acme/my-repo", entry.Repo) + assert.True(t, entry.InferenceProject.Set) + assert.Equal(t, "custom", entry.InferenceProject.Value) + assert.True(t, entry.FullsendRef.Set) + assert.Equal(t, "v3", entry.FullsendRef.Value) + assert.False(t, entry.InferenceRegion.Set) +} + +func TestNullableString_Omitted(t *testing.T) { + input := `repo: acme/test` + var entry RepoEntry + err := yaml.Unmarshal([]byte(input), &entry) + require.NoError(t, err) + assert.False(t, entry.InferenceProject.Set) + assert.False(t, entry.InferenceProject.Null) + assert.Equal(t, "", entry.InferenceProject.Value) + assert.True(t, entry.InferenceProject.IsZero()) +} + +func TestNullableString_ExplicitNull(t *testing.T) { + input := ` +repo: acme/test +inference_project: null +` + var entry RepoEntry + err := yaml.Unmarshal([]byte(input), &entry) + require.NoError(t, err) + assert.True(t, entry.InferenceProject.Set) + assert.True(t, entry.InferenceProject.Null) + assert.False(t, entry.InferenceProject.IsZero()) +} + +func TestNullableString_ExplicitValue(t *testing.T) { + input := ` +repo: acme/test +inference_project: my-project +` + var entry RepoEntry + err := yaml.Unmarshal([]byte(input), &entry) + require.NoError(t, err) + assert.True(t, entry.InferenceProject.Set) + assert.False(t, entry.InferenceProject.Null) + assert.Equal(t, "my-project", entry.InferenceProject.Value) + assert.False(t, entry.InferenceProject.IsZero()) +} + +func TestNullableString_EmptyString(t *testing.T) { + input := ` +repo: acme/test +inference_project: "" +` + var entry RepoEntry + err := yaml.Unmarshal([]byte(input), &entry) + require.NoError(t, err) + assert.True(t, entry.InferenceProject.Set) + assert.False(t, entry.InferenceProject.Null) + assert.Equal(t, "", entry.InferenceProject.Value) +} + +func TestNullableString_DirectUnmarshal(t *testing.T) { + type wrapper struct { + Field NullableString `yaml:"field"` + } + + t.Run("value", func(t *testing.T) { + var w wrapper + require.NoError(t, yaml.Unmarshal([]byte("field: hello"), &w)) + assert.True(t, w.Field.Set) + assert.False(t, w.Field.Null) + assert.Equal(t, "hello", w.Field.Value) + }) + + t.Run("null via struct leaves zero value", func(t *testing.T) { + // yaml.v3 skips UnmarshalYAML for null-tagged struct fields, + // leaving the field at its zero value. This is why RepoEntry + // uses decodeNullable for correct null detection. + var w wrapper + require.NoError(t, yaml.Unmarshal([]byte("field: null"), &w)) + assert.False(t, w.Field.Set, "yaml.v3 does not call UnmarshalYAML for null struct fields") + }) + + t.Run("null via direct node decode", func(t *testing.T) { + node := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"} + var ns NullableString + require.NoError(t, ns.UnmarshalYAML(node)) + assert.True(t, ns.Set) + assert.True(t, ns.Null) + }) + + t.Run("empty", func(t *testing.T) { + var w wrapper + require.NoError(t, yaml.Unmarshal([]byte("other: value"), &w)) + assert.False(t, w.Field.Set) + }) +} + +func TestNullableString_ReuseClears(t *testing.T) { + // Verify that unmarshalling a non-null value into a NullableString + // that previously held null clears the Null flag. + var ns NullableString + + // First: set to null. + nullNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"} + require.NoError(t, ns.UnmarshalYAML(nullNode)) + assert.True(t, ns.Null) + + // Second: set to a value — Null must be cleared. + valueNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "hello"} + require.NoError(t, ns.UnmarshalYAML(valueNode)) + assert.True(t, ns.Set) + assert.False(t, ns.Null, "Null must be cleared when decoding a non-null value") + assert.Equal(t, "hello", ns.Value) +} + +func TestDecodeNullable_ReuseClears(t *testing.T) { + var ns NullableString + + // First: decode null. + nullNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"} + require.NoError(t, decodeNullable(nullNode, &ns)) + assert.True(t, ns.Null) + + // Second: decode a value — Null must be cleared. + valueNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "world"} + require.NoError(t, decodeNullable(valueNode, &ns)) + assert.True(t, ns.Set) + assert.False(t, ns.Null, "Null must be cleared when decoding a non-null value") + assert.Equal(t, "world", ns.Value) +} + +func TestNullableString_MarshalYAML(t *testing.T) { + tests := []struct { + name string + ns NullableString + }{ + {"omitted", NullableString{}}, + {"null", NullableString{Set: true, Null: true}}, + {"value", NullableString{Set: true, Value: "hello"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + val, err := tt.ns.MarshalYAML() + require.NoError(t, err) + switch tt.name { + case "omitted": + assert.Nil(t, val) + case "null": + node, ok := val.(*yaml.Node) + require.True(t, ok) + assert.Equal(t, "!!null", node.Tag) + case "value": + assert.Equal(t, "hello", val) + } + }) + } +} + +func TestValidate_Valid(t *testing.T) { + var m Manifest + err := yaml.Unmarshal([]byte(validManifest), &m) + require.NoError(t, err) + assert.NoError(t, m.Validate()) +} + +func TestValidate_WrongVersion(t *testing.T) { + input := ` +version: 2 +mint: + url: https://mint.example.com + project: p + region: r +repos: + - acme/repo +` + var m Manifest + require.NoError(t, yaml.Unmarshal([]byte(input), &m)) + err := m.Validate() + assert.ErrorContains(t, err, "unsupported manifest version 2") +} + +func TestValidate_MissingMintURL(t *testing.T) { + input := ` +version: 1 +mint: + project: p + region: r +repos: + - acme/repo +` + var m Manifest + require.NoError(t, yaml.Unmarshal([]byte(input), &m)) + err := m.Validate() + assert.ErrorContains(t, err, "mint.url is required") +} + +func TestValidate_InvalidMintURL(t *testing.T) { + input := ` +version: 1 +mint: + url: http://not-https.example.com + project: p + region: r +repos: + - acme/repo +` + var m Manifest + require.NoError(t, yaml.Unmarshal([]byte(input), &m)) + err := m.Validate() + assert.ErrorContains(t, err, "mint.url must be a valid HTTPS URL") +} + +func TestValidate_MissingMintProject(t *testing.T) { + input := ` +version: 1 +mint: + url: https://mint.example.com + region: r +repos: + - acme/repo +` + var m Manifest + require.NoError(t, yaml.Unmarshal([]byte(input), &m)) + err := m.Validate() + assert.ErrorContains(t, err, "mint.project is required") +} + +func TestValidate_MissingMintRegion(t *testing.T) { + input := ` +version: 1 +mint: + url: https://mint.example.com + project: p +repos: + - acme/repo +` + var m Manifest + require.NoError(t, yaml.Unmarshal([]byte(input), &m)) + err := m.Validate() + assert.ErrorContains(t, err, "mint.region is required") +} + +func TestValidate_InvalidRepoFormat(t *testing.T) { + tests := []struct { + name string + entry string + }{ + {"no slash", "just-a-name"}, + {"empty owner", "/repo"}, + {"empty repo", "owner/"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input := ` +version: 1 +mint: + url: https://mint.example.com + project: p + region: r +repos: + - ` + tt.entry + ` +` + var m Manifest + require.NoError(t, yaml.Unmarshal([]byte(input), &m)) + err := m.Validate() + assert.ErrorContains(t, err, "must be in owner/repo format") + }) + } +} + +func TestValidate_EmptyRepoField(t *testing.T) { + m := Manifest{ + Version: 1, + Mint: MintConfig{ + URL: "https://mint.example.com", + Project: "p", + Region: "r", + }, + Repos: []RepoEntry{{Repo: ""}}, + } + err := m.Validate() + assert.ErrorContains(t, err, "repo field is required") +} + +func TestValidate_DuplicateRepos(t *testing.T) { + input := ` +version: 1 +mint: + url: https://mint.example.com + project: p + region: r +repos: + - acme/repo + - acme/repo +` + var m Manifest + require.NoError(t, yaml.Unmarshal([]byte(input), &m)) + err := m.Validate() + assert.ErrorContains(t, err, "duplicate repo") +} + +func TestValidate_InvalidGlob(t *testing.T) { + input := ` +version: 1 +mint: + url: https://mint.example.com + project: p + region: r +repos: + - acme/[invalid +` + var m Manifest + require.NoError(t, yaml.Unmarshal([]byte(input), &m)) + err := m.Validate() + assert.ErrorContains(t, err, "invalid glob pattern") +} + +func TestValidate_ValidGlob(t *testing.T) { + input := ` +version: 1 +mint: + url: https://mint.example.com + project: p + region: r +repos: + - acme/service-* + - acme/lib-[abc] +` + var m Manifest + require.NoError(t, yaml.Unmarshal([]byte(input), &m)) + assert.NoError(t, m.Validate()) +} + +func TestValidate_OwnerWildcard(t *testing.T) { + tests := []struct { + name string + repo string + }{ + {"star in owner", "*/service-*"}, + {"question mark in owner", "acme?/repo"}, + {"bracket in owner", "[abc]/repo"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := Manifest{ + Version: 1, + Mint: MintConfig{ + URL: "https://mint.example.com", + Project: "p", + Region: "r", + }, + Repos: []RepoEntry{{Repo: tt.repo}}, + } + err := m.Validate() + assert.ErrorContains(t, err, "glob characters are not allowed in owner segment") + }) + } +} + +func TestExpandGlobs(t *testing.T) { + input := ` +version: 1 +mint: + url: https://mint.example.com + project: p + region: r +defaults: + inference_project: default-proj +repos: + - acme/explicit-repo + - repo: acme/service-* + inference_project: glob-proj +` + var m Manifest + require.NoError(t, yaml.Unmarshal([]byte(input), &m)) + + fc := forge.NewFakeClient() + fc.Repos = []forge.Repository{ + {Name: "explicit-repo", FullName: "acme/explicit-repo"}, + {Name: "service-api", FullName: "acme/service-api"}, + {Name: "service-web", FullName: "acme/service-web"}, + {Name: "lib-utils", FullName: "acme/lib-utils"}, + // Archived/private/fork repos should be filtered by FakeClient. + {Name: "service-old", FullName: "acme/service-old", Archived: true}, + {Name: "service-priv", FullName: "acme/service-priv", Private: true}, + {Name: "service-fork", FullName: "acme/service-fork", Fork: true}, + } + + ctx := context.Background() + resolved, err := m.ExpandGlobs(ctx, fc) + require.NoError(t, err) + + // Should have: explicit-repo, service-api, service-web (not lib-utils, not archived/private/fork) + require.Len(t, resolved, 3) + + // Sorted alphabetically. + assert.Equal(t, "acme", resolved[0].Owner) + assert.Equal(t, "explicit-repo", resolved[0].Repo) + assert.Equal(t, "acme/explicit-repo", resolved[0].Entry.Repo) + + assert.Equal(t, "acme", resolved[1].Owner) + assert.Equal(t, "service-api", resolved[1].Repo) + assert.Equal(t, "glob-proj", resolved[1].Entry.InferenceProject.Value) + + assert.Equal(t, "acme", resolved[2].Owner) + assert.Equal(t, "service-web", resolved[2].Repo) +} + +func TestExpandGlobs_ExplicitWinsOverGlob(t *testing.T) { + input := ` +version: 1 +mint: + url: https://mint.example.com + project: p + region: r +repos: + - repo: acme/service-api + inference_project: explicit-proj + - repo: acme/service-* + inference_project: glob-proj +` + var m Manifest + require.NoError(t, yaml.Unmarshal([]byte(input), &m)) + + fc := forge.NewFakeClient() + fc.Repos = []forge.Repository{ + {Name: "service-api", FullName: "acme/service-api"}, + {Name: "service-web", FullName: "acme/service-web"}, + } + + ctx := context.Background() + resolved, err := m.ExpandGlobs(ctx, fc) + require.NoError(t, err) + + require.Len(t, resolved, 2) + + // service-api should use the explicit entry. + for _, rr := range resolved { + if rr.Repo == "service-api" { + assert.Equal(t, "explicit-proj", rr.Entry.InferenceProject.Value) + } + if rr.Repo == "service-web" { + assert.Equal(t, "glob-proj", rr.Entry.InferenceProject.Value) + } + } +} + +func TestExpandGlobs_ListOrgReposError(t *testing.T) { + input := ` +version: 1 +mint: + url: https://mint.example.com + project: p + region: r +repos: + - acme/* +` + var m Manifest + require.NoError(t, yaml.Unmarshal([]byte(input), &m)) + + fc := forge.NewFakeClient() + fc.Errors = map[string]error{ + "ListOrgRepos": assert.AnError, + } + + ctx := context.Background() + _, err := m.ExpandGlobs(ctx, fc) + assert.Error(t, err) + assert.ErrorContains(t, err, "expanding glob") + assert.ErrorContains(t, err, "listing repos for org") +} + +func TestExpandGlobs_NoGlobs(t *testing.T) { + input := ` +version: 1 +mint: + url: https://mint.example.com + project: p + region: r +repos: + - acme/repo-a + - acme/repo-b +` + var m Manifest + require.NoError(t, yaml.Unmarshal([]byte(input), &m)) + + fc := forge.NewFakeClient() + ctx := context.Background() + resolved, err := m.ExpandGlobs(ctx, fc) + require.NoError(t, err) + + require.Len(t, resolved, 2) + assert.Equal(t, "repo-a", resolved[0].Repo) + assert.Equal(t, "repo-b", resolved[1].Repo) +} + +func TestResolveConfig_DefaultsOnly(t *testing.T) { + var m Manifest + require.NoError(t, yaml.Unmarshal([]byte(validManifest), &m)) + + cfg, found := m.ResolveConfig("acme", "repo-one") + assert.True(t, found) + assert.Equal(t, "acme", cfg.Owner) + assert.Equal(t, "repo-one", cfg.Repo) + assert.Equal(t, "https://mint.example.com", cfg.MintURL) + assert.Equal(t, "my-project", cfg.MintProject) + assert.Equal(t, "us-central1", cfg.MintRegion) + assert.Equal(t, "default-inference", cfg.InferenceProject) + assert.Equal(t, "us-east1", cfg.InferenceRegion) + assert.Equal(t, "main", cfg.FullsendRef) + assert.Equal(t, "default-harness", cfg.BaseHarness) + assert.Equal(t, []string{"resource-a", "resource-b"}, cfg.AllowedRemoteResources) +} + +func TestResolveConfig_PerRepoOverride(t *testing.T) { + input := ` +version: 1 +mint: + url: https://mint.example.com + project: p + region: r +defaults: + inference_project: default-proj + inference_region: default-region + fullsend_ref: main + base_harness: default-harness +repos: + - repo: acme/special + inference_project: custom-proj + fullsend_ref: v2 + - acme/normal +` + var m Manifest + require.NoError(t, yaml.Unmarshal([]byte(input), &m)) + + // Per-repo overrides. + cfg, found := m.ResolveConfig("acme", "special") + assert.True(t, found) + assert.Equal(t, "custom-proj", cfg.InferenceProject) + assert.Equal(t, "default-region", cfg.InferenceRegion) // falls back to default + assert.Equal(t, "v2", cfg.FullsendRef) + assert.Equal(t, "default-harness", cfg.BaseHarness) // falls back to default + + // No overrides. + cfg2, found2 := m.ResolveConfig("acme", "normal") + assert.True(t, found2) + assert.Equal(t, "default-proj", cfg2.InferenceProject) + assert.Equal(t, "main", cfg2.FullsendRef) +} + +func TestResolveConfig_ExplicitNullOverride(t *testing.T) { + input := ` +version: 1 +mint: + url: https://mint.example.com + project: p + region: r +defaults: + inference_project: default-proj + fullsend_ref: main +repos: + - repo: acme/no-inference + inference_project: null +` + var m Manifest + require.NoError(t, yaml.Unmarshal([]byte(input), &m)) + + cfg, found := m.ResolveConfig("acme", "no-inference") + assert.True(t, found) + assert.Equal(t, "", cfg.InferenceProject) // null stops fallback + assert.Equal(t, "main", cfg.FullsendRef) // not nulled, falls through +} + +func TestResolveConfig_UnknownRepo(t *testing.T) { + var m Manifest + require.NoError(t, yaml.Unmarshal([]byte(validManifest), &m)) + + // Repo not listed in manifest; should get defaults but found=false. + cfg, found := m.ResolveConfig("acme", "unknown") + assert.False(t, found) + assert.Equal(t, "acme", cfg.Owner) + assert.Equal(t, "unknown", cfg.Repo) + assert.Equal(t, "default-inference", cfg.InferenceProject) +} + +func TestResolveConfig_MultiOrg(t *testing.T) { + input := ` +version: 1 +mint: + url: https://mint.example.com + project: p + region: r +defaults: + inference_project: default-proj +repos: + - repo: org-a/repo + inference_project: proj-a + - repo: org-b/repo + inference_project: proj-b +` + var m Manifest + require.NoError(t, yaml.Unmarshal([]byte(input), &m)) + + cfgA, foundA := m.ResolveConfig("org-a", "repo") + assert.True(t, foundA) + assert.Equal(t, "proj-a", cfgA.InferenceProject) + + cfgB, foundB := m.ResolveConfig("org-b", "repo") + assert.True(t, foundB) + assert.Equal(t, "proj-b", cfgB.InferenceProject) +} + +func TestResolveConfigForEntry_GlobExpanded(t *testing.T) { + input := ` +version: 1 +mint: + url: https://mint.example.com + project: p + region: r +defaults: + inference_project: default-proj + fullsend_ref: main +repos: + - repo: acme/service-* + inference_project: glob-proj + fullsend_ref: v3 +` + var m Manifest + require.NoError(t, yaml.Unmarshal([]byte(input), &m)) + + fc := forge.NewFakeClient() + fc.Repos = []forge.Repository{ + {Name: "service-api", FullName: "acme/service-api"}, + {Name: "service-web", FullName: "acme/service-web"}, + } + + ctx := context.Background() + resolved, err := m.ExpandGlobs(ctx, fc) + require.NoError(t, err) + require.Len(t, resolved, 2) + + for _, rr := range resolved { + cfg := m.ResolveConfigForEntry(rr.Owner, rr.Repo, rr.Entry) + assert.Equal(t, "glob-proj", cfg.InferenceProject, "glob override must be applied for %s", rr.Repo) + assert.Equal(t, "v3", cfg.FullsendRef, "glob override must be applied for %s", rr.Repo) + assert.Equal(t, "https://mint.example.com", cfg.MintURL) + } +} + +func TestLoadManifest_File(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "repos.yaml") + err := os.WriteFile(path, []byte(validManifest), 0644) + require.NoError(t, err) + + m, err := LoadManifest(context.Background(), path) + require.NoError(t, err) + assert.Equal(t, 1, m.Version) + assert.Equal(t, "https://mint.example.com", m.Mint.URL) + require.Len(t, m.Repos, 2) +} + +func TestLoadManifest_FileNotFound(t *testing.T) { + _, err := LoadManifest(context.Background(), "/nonexistent/path/repos.yaml") + assert.Error(t, err) + assert.ErrorContains(t, err, "reading manifest file") +} + +func TestFetchManifestURL_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/yaml") + _, _ = w.Write([]byte(validManifest)) + })) + defer srv.Close() + + data, err := fetchManifestURL(context.Background(), srv.URL, true) + require.NoError(t, err) + + var m Manifest + require.NoError(t, yaml.Unmarshal(data, &m)) + assert.Equal(t, 1, m.Version) + require.Len(t, m.Repos, 2) +} + +func TestFetchManifestURL_Non200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + _, err := fetchManifestURL(context.Background(), srv.URL, true) + assert.Error(t, err) + assert.ErrorContains(t, err, "HTTP 404") +} + +func TestFetchManifestURL_SSRFBlocked(t *testing.T) { + _, err := fetchManifestURL(context.Background(), "http://127.0.0.1:9999/steal", false) + require.Error(t, err) + assert.ErrorContains(t, err, "blocked") +} + +func TestLoadManifest_InvalidYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad.yaml") + err := os.WriteFile(path, []byte("version: [bad: {yaml"), 0644) + require.NoError(t, err) + + _, err = LoadManifest(context.Background(), path) + assert.Error(t, err) + assert.ErrorContains(t, err, "parsing manifest YAML") +} + +func TestLoadManifest_HTTPRejected(t *testing.T) { + _, err := LoadManifest(context.Background(), "http://example.com/repos.yaml") + assert.Error(t, err) + assert.ErrorContains(t, err, "insecure http:// not supported") +} + +func TestLoadManifest_FTPSchemeNotSupported(t *testing.T) { + _, err := LoadManifest(context.Background(), "ftp://example.com/repos.yaml") + assert.Error(t, err) + assert.ErrorContains(t, err, "reading manifest file") +} + +func TestLoadManifest_OversizedResponse(t *testing.T) { + // Create a server that returns a response larger than maxManifestBytes. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/yaml") + _, _ = w.Write([]byte(strings.Repeat("x", maxManifestBytes+100))) + })) + defer srv.Close() + + ctx := context.Background() + _, err := fetchManifestURL(ctx, srv.URL, true) + require.Error(t, err) + assert.ErrorContains(t, err, "exceeds maximum size") +} + +func TestLoadManifest_Timeout(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(5 * time.Second) + _, _ = w.Write([]byte(validManifest)) + })) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + _, err := fetchManifestURL(ctx, srv.URL, true) + require.Error(t, err) +} + +func TestMarshalRoundTrip(t *testing.T) { + var original Manifest + require.NoError(t, yaml.Unmarshal([]byte(validManifest), &original)) + + data, err := original.Marshal() + require.NoError(t, err) + + var roundTripped Manifest + require.NoError(t, yaml.Unmarshal(data, &roundTripped)) + + assert.Equal(t, original.Version, roundTripped.Version) + assert.Equal(t, original.Mint, roundTripped.Mint) + assert.Equal(t, original.Defaults, roundTripped.Defaults) + require.Len(t, roundTripped.Repos, len(original.Repos)) + for i := range original.Repos { + assert.Equal(t, original.Repos[i].Repo, roundTripped.Repos[i].Repo) + } +} + +func TestMarshalRoundTrip_WithOverrides(t *testing.T) { + input := ` +version: 1 +mint: + url: https://mint.example.com + project: p + region: r +repos: + - repo: acme/with-override + inference_project: custom + fullsend_ref: null + - acme/simple +` + var original Manifest + require.NoError(t, yaml.Unmarshal([]byte(input), &original)) + + data, err := original.Marshal() + require.NoError(t, err) + + var roundTripped Manifest + require.NoError(t, yaml.Unmarshal(data, &roundTripped)) + + require.Len(t, roundTripped.Repos, 2) + assert.Equal(t, "acme/with-override", roundTripped.Repos[0].Repo) + assert.Equal(t, "custom", roundTripped.Repos[0].InferenceProject.Value) + assert.True(t, roundTripped.Repos[0].FullsendRef.Null) + assert.Equal(t, "acme/simple", roundTripped.Repos[1].Repo) +} + +func TestResolveField(t *testing.T) { + tests := []struct { + name string + override NullableString + fallback string + builtin string + want string + }{ + { + name: "override set", + override: NullableString{Set: true, Value: "override"}, + fallback: "fallback", + builtin: "builtin", + want: "override", + }, + { + name: "override null stops chain", + override: NullableString{Set: true, Null: true}, + fallback: "fallback", + builtin: "builtin", + want: "", + }, + { + name: "override not set falls to fallback", + override: NullableString{}, + fallback: "fallback", + builtin: "builtin", + want: "fallback", + }, + { + name: "no fallback falls to builtin", + override: NullableString{}, + fallback: "", + builtin: "builtin", + want: "builtin", + }, + { + name: "all empty", + override: NullableString{}, + fallback: "", + builtin: "", + want: "", + }, + { + name: "override set to empty string falls to fallback", + override: NullableString{Set: true, Value: ""}, + fallback: "fallback", + builtin: "builtin", + want: "fallback", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolveField(tt.override, tt.fallback, tt.builtin) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFetchManifestURL_RedirectToHTTPRejected(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "http://evil.example.com/steal", http.StatusFound) + })) + defer srv.Close() + + _, err := fetchManifestURL(context.Background(), srv.URL, true) + require.Error(t, err) + assert.ErrorContains(t, err, "redirect to non-HTTPS URL") +} + +func TestLoadManifest_OversizedLocalFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "huge.yaml") + err := os.WriteFile(path, []byte(strings.Repeat("x", maxManifestBytes+100)), 0644) + require.NoError(t, err) + + _, err = LoadManifest(context.Background(), path) + require.Error(t, err) + assert.ErrorContains(t, err, "exceeds maximum size") +} + +func TestExpandGlobs_MultiOrg(t *testing.T) { + input := ` +version: 1 +mint: + url: https://mint.example.com + project: p + region: r +repos: + - org-a/* + - org-b/service-* +` + var m Manifest + require.NoError(t, yaml.Unmarshal([]byte(input), &m)) + + fc := forge.NewFakeClient() + fc.OrgRepos = map[string][]forge.Repository{ + "org-a": { + {Name: "app", FullName: "org-a/app"}, + {Name: "lib", FullName: "org-a/lib"}, + }, + "org-b": { + {Name: "service-api", FullName: "org-b/service-api"}, + {Name: "other", FullName: "org-b/other"}, + }, + } + + ctx := context.Background() + resolved, err := m.ExpandGlobs(ctx, fc) + require.NoError(t, err) + + // org-a/* matches app, lib (from org-a). + // org-b/service-* matches only service-api (from org-b). + require.Len(t, resolved, 3) + + repoNames := make(map[string]bool) + for _, rr := range resolved { + repoNames[rr.Owner+"/"+rr.Repo] = true + } + assert.True(t, repoNames["org-a/app"]) + assert.True(t, repoNames["org-a/lib"]) + assert.True(t, repoNames["org-b/service-api"]) + assert.False(t, repoNames["org-b/other"], "other should not match service-*") +}