From 4dc282651af3d1b87af5e8f206ddee1d1f2fbb5a Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Mon, 8 Jun 2026 16:10:51 -0500 Subject: [PATCH 1/2] Reject zero-value branch, owner_id, and repo_id in lockfile Parse now enforces that required action fields carry meaningful values, not just that the keys are present: - branch must be a non-empty string - owner_id and repo_id must be positive integers (> 0) A present-but-zero-value field would silently disable the security check it is meant to enforce (e.g. owner_id: 0 bypasses owner identity verification). The JSON schema is tightened with minLength and minimum constraints to match. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/pkg/lockfile/lockfile.go | 49 +++++++++++++++++++++++ go/pkg/lockfile/schema_gen.go | 2 +- go/pkg/lockfile/schema_test.go | 72 ++++++++++++++++++++++++++++++++++ schema/lockfile-v0.0.1.json | 9 +++-- 4 files changed, 128 insertions(+), 4 deletions(-) diff --git a/go/pkg/lockfile/lockfile.go b/go/pkg/lockfile/lockfile.go index ad76bd6..f05c3aa 100644 --- a/go/pkg/lockfile/lockfile.go +++ b/go/pkg/lockfile/lockfile.go @@ -377,6 +377,55 @@ func validateKnownFields(f *File) *ParseError { } } } + // Enforce non-zero values for fields where the zero value is + // meaningless and would silently disable a security check. + if pe := rejectZeroValues(action, pinKey.Value); pe != nil { + return pe + } + } + return nil +} + +// nonEmptyStringKeys lists action fields that must be non-empty strings. +var nonEmptyStringKeys = map[string]struct{}{ + "branch": {}, +} + +// positiveIntKeys lists action fields that must be positive integers (> 0). +var positiveIntKeys = map[string]struct{}{ + "owner_id": {}, + "repo_id": {}, +} + +// rejectZeroValues checks that required action fields carry meaningful values: +// string fields like "branch" must be non-empty, and integer ID fields like +// "owner_id" and "repo_id" must be positive. A present-but-zero-value field +// would silently disable the security check it's meant to enforce. +func rejectZeroValues(action *yaml.Node, dep string) *ParseError { + for j := 0; j+1 < len(action.Content); j += 2 { + key := action.Content[j] + val := action.Content[j+1] + + if _, ok := nonEmptyStringKeys[key.Value]; ok { + if val.Value == "" { + return &ParseError{ + Line: val.Line, + Column: val.Column, + Msg: fmt.Sprintf("action field %q must not be empty for dependency %q", key.Value, dep), + } + } + } + + if _, ok := positiveIntKeys[key.Value]; ok { + n, err := strconv.ParseInt(val.Value, 10, 64) + if err != nil || n <= 0 { + return &ParseError{ + Line: val.Line, + Column: val.Column, + Msg: fmt.Sprintf("action field %q must be a positive integer for dependency %q", key.Value, dep), + } + } + } } return nil } diff --git a/go/pkg/lockfile/schema_gen.go b/go/pkg/lockfile/schema_gen.go index 25f1178..a0e4108 100644 --- a/go/pkg/lockfile/schema_gen.go +++ b/go/pkg/lockfile/schema_gen.go @@ -2,4 +2,4 @@ package lockfile -const schemaV001 = "{\n \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n \"$id\": \"https://gh.io/actions-lockfile/v0.0.1.json\",\n \"title\": \"GitHub Actions dependency lockfile\",\n \"description\": \"Machine-generated lockfile describing the pinned action dependency graph for a repository's workflows. Written and updated by `gh actions-pin`.\",\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"required\": [\"version\"],\n \"properties\": {\n \"version\": {\n \"description\": \"Lockfile schema version. Only v0.0.1 is supported.\",\n \"const\": \"v0.0.1\"\n },\n \"workflows\": {\n \"description\": \"Map of repo-relative workflow path to the flat, transitive list of canonical pin keys it depends on.\",\n \"type\": \"object\",\n \"additionalProperties\": {\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/pin\" }\n }\n },\n \"dependencies\": {\n \"description\": \"Deduplicated action graph keyed by canonical pin. Each entry records the resolved metadata for one action tarball.\",\n \"type\": \"object\",\n \"additionalProperties\": { \"$ref\": \"#/$defs/action\" }\n }\n },\n \"$defs\": {\n \"pin\": {\n \"description\": \"Canonical dependency pin: OWNER/REPO@REF:ALGO-HEX (e.g. actions/checkout@v4:sha1-34e1...).\",\n \"type\": \"string\",\n \"pattern\": \"^[^/@:]+/[^/@:]+@[^:]+:(sha1-[0-9a-f]{40}|sha256-[0-9a-f]{64})$\"\n },\n \"action\": {\n \"description\": \"Resolved metadata for a single pinned action.\",\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"required\": [\"branch\", \"commit\", \"owner_id\", \"repo_id\"],\n \"properties\": {\n \"tag\": {\n \"description\": \"The release or tag the commit was published as, if any. Optional: not every pinned commit corresponds to a tag.\",\n \"type\": \"string\"\n },\n \"branch\": {\n \"description\": \"A branch in the action's repository that contains the pinned commit. Required: it is the authenticity check. A legitimate release commit lives on a branch, while a commit that exists only as a dangling object — pushed to a fork or attached to a PR and never merged — belongs to no branch. Pinning by SHA alone can be tricked into trusting such an impostor commit; verifying the commit is reachable from a branch closes that gap.\",\n \"type\": \"string\"\n },\n \"commit\": {\n \"description\": \"The exact commit the action resolves to, in algo-prefixed digest form (e.g. sha1-...). This is the immutable identity the runner checks out; tags and branches can be moved, this cannot.\",\n \"type\": \"string\"\n },\n \"owner_id\": {\n \"description\": \"The numeric ID of the action's owner (user or org). Pinned because names can be deleted and re-registered by someone else; the ID cannot, so it ties the pin to the original owner.\",\n \"type\": \"integer\"\n },\n \"repo_id\": {\n \"description\": \"The numeric ID of the action's repository. Pinned because a repo can be renamed or deleted and the name reclaimed; the ID detects that the repo behind the name has changed.\",\n \"type\": \"integer\"\n },\n \"uses\": {\n \"description\": \"The action's own direct dependencies, as canonical pin keys, so the full dependency graph stays pinned and verifiable end to end. Required for composite actions (which can call other actions); absent for leaf actions that have no dependencies of their own.\",\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/pin\" }\n }\n }\n }\n }\n}\n" +const schemaV001 = "{\n \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n \"$id\": \"https://gh.io/actions-lockfile/v0.0.1.json\",\n \"title\": \"GitHub Actions dependency lockfile\",\n \"description\": \"Machine-generated lockfile describing the pinned action dependency graph for a repository's workflows. Written and updated by `gh actions-pin`.\",\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"required\": [\"version\"],\n \"properties\": {\n \"version\": {\n \"description\": \"Lockfile schema version. Only v0.0.1 is supported.\",\n \"const\": \"v0.0.1\"\n },\n \"workflows\": {\n \"description\": \"Map of repo-relative workflow path to the flat, transitive list of canonical pin keys it depends on.\",\n \"type\": \"object\",\n \"additionalProperties\": {\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/pin\" }\n }\n },\n \"dependencies\": {\n \"description\": \"Deduplicated action graph keyed by canonical pin. Each entry records the resolved metadata for one action tarball.\",\n \"type\": \"object\",\n \"additionalProperties\": { \"$ref\": \"#/$defs/action\" }\n }\n },\n \"$defs\": {\n \"pin\": {\n \"description\": \"Canonical dependency pin: OWNER/REPO@REF:ALGO-HEX (e.g. actions/checkout@v4:sha1-34e1...).\",\n \"type\": \"string\",\n \"pattern\": \"^[^/@:]+/[^/@:]+@[^:]+:(sha1-[0-9a-f]{40}|sha256-[0-9a-f]{64})$\"\n },\n \"action\": {\n \"description\": \"Resolved metadata for a single pinned action.\",\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"required\": [\"branch\", \"commit\", \"owner_id\", \"repo_id\"],\n \"properties\": {\n \"tag\": {\n \"description\": \"The release or tag the commit was published as, if any. Optional: not every pinned commit corresponds to a tag.\",\n \"type\": \"string\"\n },\n \"branch\": {\n \"description\": \"A branch in the action's repository that contains the pinned commit. Required: it is the authenticity check. A legitimate release commit lives on a branch, while a commit that exists only as a dangling object — pushed to a fork or attached to a PR and never merged — belongs to no branch. Pinning by SHA alone can be tricked into trusting such an impostor commit; verifying the commit is reachable from a branch closes that gap.\",\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"commit\": {\n \"description\": \"The exact commit the action resolves to, in algo-prefixed digest form (e.g. sha1-...). This is the immutable identity the runner checks out; tags and branches can be moved, this cannot.\",\n \"type\": \"string\"\n },\n \"owner_id\": {\n \"description\": \"The numeric ID of the action's owner (user or org). Pinned because names can be deleted and re-registered by someone else; the ID cannot, so it ties the pin to the original owner.\",\n \"type\": \"integer\",\n \"minimum\": 1\n },\n \"repo_id\": {\n \"description\": \"The numeric ID of the action's repository. Pinned because a repo can be renamed or deleted and the name reclaimed; the ID detects that the repo behind the name has changed.\",\n \"type\": \"integer\",\n \"minimum\": 1\n },\n \"uses\": {\n \"description\": \"The action's own direct dependencies, as canonical pin keys, so the full dependency graph stays pinned and verifiable end to end. Required for composite actions (which can call other actions); absent for leaf actions that have no dependencies of their own.\",\n \"type\": \"array\",\n \"items\": { \"$ref\": \"#/$defs/pin\" }\n }\n }\n }\n }\n}\n" diff --git a/go/pkg/lockfile/schema_test.go b/go/pkg/lockfile/schema_test.go index bea61b2..b67101d 100644 --- a/go/pkg/lockfile/schema_test.go +++ b/go/pkg/lockfile/schema_test.go @@ -117,6 +117,78 @@ dependencies: assert.Greater(t, pe.Column, 0, "expected a column anchored on the pin key") } +func TestParse_EmptyBranchRejected(t *testing.T) { + yaml := `version: v0.0.1 +dependencies: + actions/checkout@v4:sha1-34e114876b0b11c390a56381ad16ebd13914f8d5: + branch: "" + commit: sha1-34e114876b0b11c390a56381ad16ebd13914f8d5 + owner_id: 1 + repo_id: 2 +` + _, err := Parse([]byte(yaml)) + require.Error(t, err) + + var pe *ParseError + require.True(t, errors.As(err, &pe), "expected a *ParseError, got %T", err) + assert.Contains(t, pe.Msg, `"branch"`) + assert.Contains(t, pe.Msg, "must not be empty") +} + +func TestParse_ZeroOwnerIDRejected(t *testing.T) { + yaml := `version: v0.0.1 +dependencies: + actions/checkout@v4:sha1-34e114876b0b11c390a56381ad16ebd13914f8d5: + branch: main + commit: sha1-34e114876b0b11c390a56381ad16ebd13914f8d5 + owner_id: 0 + repo_id: 2 +` + _, err := Parse([]byte(yaml)) + require.Error(t, err) + + var pe *ParseError + require.True(t, errors.As(err, &pe), "expected a *ParseError, got %T", err) + assert.Contains(t, pe.Msg, `"owner_id"`) + assert.Contains(t, pe.Msg, "must be a positive integer") +} + +func TestParse_ZeroRepoIDRejected(t *testing.T) { + yaml := `version: v0.0.1 +dependencies: + actions/checkout@v4:sha1-34e114876b0b11c390a56381ad16ebd13914f8d5: + branch: main + commit: sha1-34e114876b0b11c390a56381ad16ebd13914f8d5 + owner_id: 1 + repo_id: 0 +` + _, err := Parse([]byte(yaml)) + require.Error(t, err) + + var pe *ParseError + require.True(t, errors.As(err, &pe), "expected a *ParseError, got %T", err) + assert.Contains(t, pe.Msg, `"repo_id"`) + assert.Contains(t, pe.Msg, "must be a positive integer") +} + +func TestParse_NegativeIDRejected(t *testing.T) { + yaml := `version: v0.0.1 +dependencies: + actions/checkout@v4:sha1-34e114876b0b11c390a56381ad16ebd13914f8d5: + branch: main + commit: sha1-34e114876b0b11c390a56381ad16ebd13914f8d5 + owner_id: -1 + repo_id: 2 +` + _, err := Parse([]byte(yaml)) + require.Error(t, err) + + var pe *ParseError + require.True(t, errors.As(err, &pe), "expected a *ParseError, got %T", err) + assert.Contains(t, pe.Msg, `"owner_id"`) + assert.Contains(t, pe.Msg, "must be a positive integer") +} + func TestParse_KnownFieldsAccepted(t *testing.T) { yaml := `version: v0.0.1 workflows: diff --git a/schema/lockfile-v0.0.1.json b/schema/lockfile-v0.0.1.json index f71312f..3aad9a5 100644 --- a/schema/lockfile-v0.0.1.json +++ b/schema/lockfile-v0.0.1.json @@ -43,7 +43,8 @@ }, "branch": { "description": "A branch in the action's repository that contains the pinned commit. Required: it is the authenticity check. A legitimate release commit lives on a branch, while a commit that exists only as a dangling object — pushed to a fork or attached to a PR and never merged — belongs to no branch. Pinning by SHA alone can be tricked into trusting such an impostor commit; verifying the commit is reachable from a branch closes that gap.", - "type": "string" + "type": "string", + "minLength": 1 }, "commit": { "description": "The exact commit the action resolves to, in algo-prefixed digest form (e.g. sha1-...). This is the immutable identity the runner checks out; tags and branches can be moved, this cannot.", @@ -51,11 +52,13 @@ }, "owner_id": { "description": "The numeric ID of the action's owner (user or org). Pinned because names can be deleted and re-registered by someone else; the ID cannot, so it ties the pin to the original owner.", - "type": "integer" + "type": "integer", + "minimum": 1 }, "repo_id": { "description": "The numeric ID of the action's repository. Pinned because a repo can be renamed or deleted and the name reclaimed; the ID detects that the repo behind the name has changed.", - "type": "integer" + "type": "integer", + "minimum": 1 }, "uses": { "description": "The action's own direct dependencies, as canonical pin keys, so the full dependency graph stays pinned and verifiable end to end. Required for composite actions (which can call other actions); absent for leaf actions that have no dependencies of their own.", From 8930c626c468dfee76d7c62c4564e7dd83030b72 Mon Sep 17 00:00:00 2001 From: Jeff Martin Date: Fri, 12 Jun 2026 09:29:01 -0500 Subject: [PATCH 2/2] Parse: scope per-dependency validation to requested workflow paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add variadic paths parameter to Parse(contents, paths...) so callers can limit per-dependency checks (unknown keys, required keys, zero-value rejection) to only the entries referenced by the named workflows. When paths is empty, every dependency is validated — back-compat for whole-file tooling (CLI regen, Dependabot, existing callers compile unchanged). When paths is non-empty, entries outside the referenced set are skipped. A path absent from f.Workflows contributes zero entries and validates nothing (fail-open for non-onboarded workflows). Document-level invariants (version, unknown top-level keys) always run regardless of paths. This fixes the blast-radius bug where one corrupt dependency entry in a shared lockfile fails every workflow that loads it, including workflows that don't reference the corrupt entry at all. --- go/pkg/lockfile/lockfile.go | 45 ++++++++- go/pkg/lockfile/schema_test.go | 167 +++++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+), 3 deletions(-) diff --git a/go/pkg/lockfile/lockfile.go b/go/pkg/lockfile/lockfile.go index f05c3aa..110202d 100644 --- a/go/pkg/lockfile/lockfile.go +++ b/go/pkg/lockfile/lockfile.go @@ -242,13 +242,25 @@ type Action struct { // existence. That belongs to the consumer (e.g. gh-actions-pin's check // command). // +// The optional paths parameter scopes per-dependency validation to only the +// entries referenced by the named workflow paths (via f.Workflows[p]). +// When paths is empty, every dependency entry is validated — the default for +// whole-file tooling (CLI regen, Dependabot). When paths is non-empty, a +// dependency entry outside the referenced set is left unchecked so one +// corrupt entry doesn't fail unrelated workflows that share the lockfile. +// A requested path absent from f.Workflows contributes zero entries and +// validates nothing — fail-open by design for workflows not yet onboarded. +// +// Document-level invariants (version required/supported, unknown top-level +// keys) always run regardless of paths. +// // Action map keys and workflow dependency entries are canonicalized via // ParsePin so downstream lookups by canonical key (e.g. pin.String()) match // regardless of the source casing of owner/repo/algo/hex in the YAML. // Entries that do not parse as a valid pin are left untouched; consumers // can flag them via diagnostics. Workflow path keys are NOT canonicalized // — filesystem paths are case-sensitive on the platforms we run on. -func Parse(contents []byte) (File, error) { +func Parse(contents []byte, paths ...string) (File, error) { var root yaml.Node if err := yaml.Unmarshal(contents, &root); err != nil { return File{}, newYAMLParseError(err) @@ -286,7 +298,7 @@ func Parse(contents []byte) (File, error) { } return File{}, pe } - if pe := validateKnownFields(&f); pe != nil { + if pe := validateKnownFields(&f, paths); pe != nil { return File{}, pe } if conflictKey, err := canonicalizeActions(&f); err != nil { @@ -333,7 +345,13 @@ var requiredActionKeys = []string{"branch", "commit", "owner_id", "repo_id"} // matching the stricter parsing the embedded schema describes. Map-valued // sections (workflow paths, dependency pin keys) carry arbitrary data keys and // are intentionally not constrained here. -func validateKnownFields(f *File) *ParseError { +// +// When paths is non-empty, per-dependency checks (unknown keys, required keys, +// zero-value rejection) are scoped to only the dependency entries referenced +// by the union of f.Workflows[p] for each requested path. This prevents a +// single corrupt entry from failing every workflow that shares the lockfile. +// When paths is empty, every dependency entry is validated. +func validateKnownFields(f *File, paths []string) *ParseError { root := docMapping(f.node) if root == nil { return nil @@ -348,12 +366,33 @@ func validateKnownFields(f *File) *ParseError { if deps == nil || deps.Kind != yaml.MappingNode { return nil } + + // Build the in-scope set from the raw (pre-canonicalization) workflow + // entries. nil means "validate all" (len(paths)==0); a non-nil but empty + // map means "validate nothing" (requested paths had no matching deps). + var inScope map[string]struct{} + if len(paths) > 0 { + inScope = make(map[string]struct{}) + for _, p := range paths { + for _, pin := range f.Workflows[p] { + inScope[pin] = struct{}{} + } + } + } + for i := 0; i+1 < len(deps.Content); i += 2 { pinKey := deps.Content[i] action := deps.Content[i+1] if action.Kind != yaml.MappingNode { continue } + + if inScope != nil { + if _, ok := inScope[pinKey.Value]; !ok { + continue + } + } + present := make(map[string]struct{}, len(action.Content)/2) for j := 0; j+1 < len(action.Content); j += 2 { ak := action.Content[j] diff --git a/go/pkg/lockfile/schema_test.go b/go/pkg/lockfile/schema_test.go index b67101d..a223f1a 100644 --- a/go/pkg/lockfile/schema_test.go +++ b/go/pkg/lockfile/schema_test.go @@ -209,3 +209,170 @@ dependencies: assert.Len(t, f.Dependencies, 1) assert.Contains(t, f.Workflows, ".github/workflows/ci.yml") } + +// corruptLockfile is a shared fixture for scoped-validation tests. +// It has two deps: goodPin is valid, corruptPin is missing the required +// "branch" field. Workflow A references only the good dep, workflow B +// references only the corrupt dep. +const ( + goodPin = "actions/checkout@v4:sha1-34e114876b0b11c390a56381ad16ebd13914f8d5" + corruptPin = "actions/setup-go@v5:sha1-0000000000000000000000000000000000000000" + + corruptLockfile = `version: v0.0.1 +workflows: + .github/workflows/a.yml: + - actions/checkout@v4:sha1-34e114876b0b11c390a56381ad16ebd13914f8d5 + .github/workflows/b.yml: + - actions/setup-go@v5:sha1-0000000000000000000000000000000000000000 +dependencies: + actions/checkout@v4:sha1-34e114876b0b11c390a56381ad16ebd13914f8d5: + branch: main + commit: sha1-34e114876b0b11c390a56381ad16ebd13914f8d5 + owner_id: 1 + repo_id: 2 + actions/setup-go@v5:sha1-0000000000000000000000000000000000000000: + commit: sha1-0000000000000000000000000000000000000000 + owner_id: 3 + repo_id: 4 +` +) + +func TestParse_ScopedValidation_NoPaths_ErrorsOnCorruptEntry(t *testing.T) { + _, err := Parse([]byte(corruptLockfile)) + require.Error(t, err) + + var pe *ParseError + require.True(t, errors.As(err, &pe)) + assert.Contains(t, pe.Msg, `missing required action field "branch"`) + assert.Contains(t, pe.Msg, corruptPin) +} + +func TestParse_ScopedValidation_GoodPathOnly_OK(t *testing.T) { + f, err := Parse([]byte(corruptLockfile), ".github/workflows/a.yml") + require.NoError(t, err) + assert.Len(t, f.Dependencies, 2, "both deps should be present even though only one was validated") +} + +func TestParse_ScopedValidation_CorruptPathOnly_Errors(t *testing.T) { + _, err := Parse([]byte(corruptLockfile), ".github/workflows/b.yml") + require.Error(t, err) + + var pe *ParseError + require.True(t, errors.As(err, &pe)) + assert.Contains(t, pe.Msg, `missing required action field "branch"`) + assert.Contains(t, pe.Msg, corruptPin) +} + +func TestParse_ScopedValidation_AbsentPath_FailOpen(t *testing.T) { + f, err := Parse([]byte(corruptLockfile), ".github/workflows/c.yml") + require.NoError(t, err) + assert.Len(t, f.Dependencies, 2) +} + +func TestParse_ScopedValidation_GoodAndCorruptPaths_Errors(t *testing.T) { + _, err := Parse([]byte(corruptLockfile), ".github/workflows/a.yml", ".github/workflows/b.yml") + require.Error(t, err) + + var pe *ParseError + require.True(t, errors.As(err, &pe)) + assert.Contains(t, pe.Msg, `missing required action field "branch"`) +} + +func TestParse_ScopedValidation_UnknownActionField_InScope_Errors(t *testing.T) { + data := `version: v0.0.1 +workflows: + .github/workflows/a.yml: + - actions/checkout@v4:sha1-34e114876b0b11c390a56381ad16ebd13914f8d5 +dependencies: + actions/checkout@v4:sha1-34e114876b0b11c390a56381ad16ebd13914f8d5: + branch: main + commit: sha1-34e114876b0b11c390a56381ad16ebd13914f8d5 + owner_id: 1 + repo_id: 2 + flavor: spicy +` + _, err := Parse([]byte(data), ".github/workflows/a.yml") + require.Error(t, err) + + var pe *ParseError + require.True(t, errors.As(err, &pe)) + assert.Contains(t, pe.Msg, `unknown action field "flavor"`) +} + +func TestParse_ScopedValidation_UnknownActionField_OutOfScope_OK(t *testing.T) { + data := `version: v0.0.1 +workflows: + .github/workflows/b.yml: + - actions/checkout@v4:sha1-34e114876b0b11c390a56381ad16ebd13914f8d5 +dependencies: + actions/checkout@v4:sha1-34e114876b0b11c390a56381ad16ebd13914f8d5: + branch: main + commit: sha1-34e114876b0b11c390a56381ad16ebd13914f8d5 + owner_id: 1 + repo_id: 2 + flavor: spicy +` + // "a.yml" is not in the workflows map, so the dep is out of scope. + _, err := Parse([]byte(data), ".github/workflows/a.yml") + require.NoError(t, err) +} + +func TestParse_ScopedValidation_ZeroValue_InScope_Errors(t *testing.T) { + data := `version: v0.0.1 +workflows: + .github/workflows/a.yml: + - actions/checkout@v4:sha1-34e114876b0b11c390a56381ad16ebd13914f8d5 +dependencies: + actions/checkout@v4:sha1-34e114876b0b11c390a56381ad16ebd13914f8d5: + branch: main + commit: sha1-34e114876b0b11c390a56381ad16ebd13914f8d5 + owner_id: 0 + repo_id: 2 +` + _, err := Parse([]byte(data), ".github/workflows/a.yml") + require.Error(t, err) + + var pe *ParseError + require.True(t, errors.As(err, &pe)) + assert.Contains(t, pe.Msg, `"owner_id"`) + assert.Contains(t, pe.Msg, "must be a positive integer") +} + +func TestParse_ScopedValidation_ZeroValue_OutOfScope_OK(t *testing.T) { + data := `version: v0.0.1 +workflows: + .github/workflows/a.yml: + - actions/checkout@v4:sha1-34e114876b0b11c390a56381ad16ebd13914f8d5 +dependencies: + actions/checkout@v4:sha1-34e114876b0b11c390a56381ad16ebd13914f8d5: + branch: main + commit: sha1-34e114876b0b11c390a56381ad16ebd13914f8d5 + owner_id: 0 + repo_id: 2 +` + // Ask for a path that doesn't reference this dep. + _, err := Parse([]byte(data), ".github/workflows/other.yml") + require.NoError(t, err) +} + +func TestParse_ScopedValidation_UnknownTopLevel_StillErrors(t *testing.T) { + data := `version: v0.0.1 +typo_section: {} +dependencies: {} +` + _, err := Parse([]byte(data), ".github/workflows/a.yml") + require.Error(t, err) + + var pe *ParseError + require.True(t, errors.As(err, &pe)) + assert.Contains(t, pe.Msg, `unknown lockfile field "typo_section"`) +} + +func TestParse_ScopedValidation_BadVersion_StillErrors(t *testing.T) { + data := `version: garbage +dependencies: {} +` + _, err := Parse([]byte(data), ".github/workflows/a.yml") + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported dependency lockfile version") +}