diff --git a/pymage/internal/lock/lock.go b/pymage/internal/lock/lock.go index 57e3dbe..66abe02 100644 --- a/pymage/internal/lock/lock.go +++ b/pymage/internal/lock/lock.go @@ -28,11 +28,17 @@ type Requirement struct { } var ( - // pinRE matches "name==version" optionally followed by extras/markers we - // ignore for matching purposes. - pinRE = regexp.MustCompile(`^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*==\s*([^\s;]+)`) + // pinRE matches "name[extras]==version". Extras are accepted (and ignored + // for matching, since a resolved lock pins their dependencies separately); + // environment markers after ";" are also ignored. + pinRE = regexp.MustCompile(`^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*==\s*([^\s;]+)`) hashRE = regexp.MustCompile(`--hash=sha256:([0-9a-fA-F]{64})`) normRE = regexp.MustCompile(`[-_.]+`) + + // dependencyOptions reference further requirements rather than configuring + // the resolver. Silently skipping them could omit dependencies, so we + // reject them: a lock fed to pymage must be fully inlined and pinned. + dependencyOptions = []string{"-e", "--editable", "-r", "--requirement", "-c", "--constraint"} ) // NormalizeName implements PEP 503 name normalization. @@ -66,9 +72,11 @@ func Parse(r io.Reader) ([]Requirement, error) { if strings.TrimSpace(stripped) == "" { return nil } - // Skip pip options like -r/-c/--index-url that aren't pins. + // Configuration options (e.g. --index-url) aren't pins and are skipped, + // but options that pull in other requirements must not be silently + // ignored. if isOption(stripped) { - return nil + return optionError(stripped) } m := pinRE.FindStringSubmatch(stripped) if m == nil { @@ -127,6 +135,18 @@ func isOption(s string) bool { return strings.HasPrefix(t, "-") } +// optionError returns an error for dependency-bearing options (which we cannot +// honor from a local wheelhouse), and nil for benign configuration options. +func optionError(s string) error { + t := strings.TrimSpace(s) + for _, opt := range dependencyOptions { + if t == opt || strings.HasPrefix(t, opt+" ") || strings.HasPrefix(t, opt+"=") { + return fmt.Errorf("lock: unsupported option %q; referenced requirements must be inlined and pinned", t) + } + } + return nil +} + // Validate ensures every requirement is pinned and (optionally) hashed. func Validate(reqs []Requirement, requireHashes bool) error { for _, r := range reqs { diff --git a/pymage/internal/lock/lock_test.go b/pymage/internal/lock/lock_test.go index 15b44e1..c3005a8 100644 --- a/pymage/internal/lock/lock_test.go +++ b/pymage/internal/lock/lock_test.go @@ -65,6 +65,10 @@ func TestParseRejectsUnsupportedLines(t *testing.T) { "requests>=2.0", // not pinned with == "mypkg @ https://example.com/x.whl", // direct URL reference "justaname", // bare name + "-e ./local", // editable reference + "--editable=./local", // editable reference + "-r other.txt", // nested requirements + "-c constraints.txt", // nested constraints } { if _, err := Parse(strings.NewReader(in)); err == nil { t.Errorf("expected error for unsupported line %q", in) @@ -72,6 +76,22 @@ func TestParseRejectsUnsupportedLines(t *testing.T) { } } +func TestParseAllowsExtrasAndBenignOptions(t *testing.T) { + in := `--index-url https://pypi.org/simple +requests[socks,security]==2.31.0 --hash=sha256:` + strings.Repeat("a", 64) + ` +` + reqs, err := Parse(strings.NewReader(in)) + if err != nil { + t.Fatal(err) + } + if len(reqs) != 1 || reqs[0].Name != "requests" || reqs[0].Version != "2.31.0" { + t.Fatalf("got %+v", reqs) + } + if len(reqs[0].Hashes) != 1 { + t.Errorf("expected the hash to be parsed, got %v", reqs[0].Hashes) + } +} + func TestNormalizeName(t *testing.T) { for _, tc := range []struct{ in, want string }{ {"Flask", "flask"},