diff --git a/.changes/unreleased/+local-source-exclusions-and-hatchling-sdists.yaml b/.changes/unreleased/+local-source-exclusions-and-hatchling-sdists.yaml new file mode 100644 index 00000000..5d73723c --- /dev/null +++ b/.changes/unreleased/+local-source-exclusions-and-hatchling-sdists.yaml @@ -0,0 +1,3 @@ +kind: Fixed +body: Allow local Python overrides to exclude exact generated subtrees before source observation, and accept valid Hatchling source distributions whose single root directory is implicit in archive entries. +time: 2026-08-16T05:45:00+08:00 diff --git a/docs/.review/APT_PROVIDER.md b/docs/.review/APT_PROVIDER.md index be97e031..660818db 100644 --- a/docs/.review/APT_PROVIDER.md +++ b/docs/.review/APT_PROVIDER.md @@ -3,17 +3,17 @@ artifact: swe-design-review-attestation schema_version: 2 scope_key: bd8b79396ed3ab3d8e062279e409430bcf54daf5acf561e1b5d63f4302f9edc4 scope: {"kind": "path", "primary_target": "docs/APT_PROVIDER.md", "repository": "/home/omry/dev/reploy", "selector": "docs/APT_PROVIDER.md"} -review_content_identity_sha256: 4ced672e66a1f128c825aaa3e0c9d06ef2335784d6a058d18871590e44c2a7e7 -target_content_identity_sha256: 863f6a96ed723af42eec92f3e422acdfe439fd8f2ef226a7f31bda5d8360d690 +review_content_identity_sha256: 3cb75520f49dee8255a7bc807e55670c1c59c75981b5753cb3c94ad29749b593 +target_content_identity_sha256: 5c8bcb33496743ccef81f81684cf38482d1a9b225e096f47b328b95ea049a68a baseline_content_identity_sha256: 9f98a355bf1d27e1c417bb5080a2f86eda476110f89ccf264d5ba02f7ee8d13b -target_documents: [{"path": "docs/APT_PROVIDER.md", "repository": "/home/omry/dev/reploy", "sha256": "281e4c65fc5e3e1bd1584ad2543f6388e631ab364b9b00c7630e342dacdae323"}] +target_documents: [{"path": "docs/APT_PROVIDER.md", "repository": "/home/omry/dev/reploy", "sha256": "be2008134ae557cf275893325fcd7caae7588ced609c5dc05bf142ad0a6aa60c"}] baseline_documents: [{"path": "docs/BLUEPRINT_ENVIRONMENT_MODEL.md", "repository": "/home/omry/dev/reploy", "sha256": "8969ffde4d2f20d2e02fb530d0e9688ed63d9dd8962a9a700473d4a6a54e7ef4"}] document_repository: "/home/omry/dev/reploy" document_path: "docs/APT_PROVIDER.md" -document_revision_provenance: "c68e86950815a95fb257f216d3a5183eaf017461" -document_sha256: 281e4c65fc5e3e1bd1584ad2543f6388e631ab364b9b00c7630e342dacdae323 +document_revision_provenance: "a13e17402b578d5f6c6922ac82773ad2f116237f" +document_sha256: be2008134ae557cf275893325fcd7caae7588ced609c5dc05bf142ad0a6aa60c verdict: clean -attested_at: 2026-08-22T13:27:58Z +attested_at: 2026-08-22T19:51:20Z --- diff --git a/docs/.review/APT_PROVIDER_DETAIL_DESIGN.md b/docs/.review/APT_PROVIDER_DETAIL_DESIGN.md index 8f6f1063..cbcf3b74 100644 --- a/docs/.review/APT_PROVIDER_DETAIL_DESIGN.md +++ b/docs/.review/APT_PROVIDER_DETAIL_DESIGN.md @@ -3,17 +3,17 @@ artifact: swe-design-review-attestation schema_version: 2 scope_key: dd04dd2c2041a5035ffd15d245763f28fd28d9c4d7c4e83ca3a0cb45f092a863 scope: {"kind": "path", "primary_target": "docs/APT_PROVIDER_DETAIL_DESIGN.md", "repository": "/home/omry/dev/reploy", "selector": "docs/APT_PROVIDER_DETAIL_DESIGN.md"} -review_content_identity_sha256: 9ee4d59e64a734c7570535405a019153d9f3ce7293b87fc85bfaf0eb31edaaf5 +review_content_identity_sha256: 8ba6a9a8660c148eaa26c1de35dd900e98f0d6e274c9cfb4d0b1036fb17f56ff target_content_identity_sha256: e93421059c4ba62f015aa08c248901201e6f091a26fdee58614dbf6e684456bb -baseline_content_identity_sha256: abb90da6717c7b7ed36addc0bf1a9dbaed6c93d144f6c768d82f85d460d3cdc7 +baseline_content_identity_sha256: 3f6f4ef49e1b4270a8aefc5120f8d86e3837e74390ea648dc09505d2047f53f4 target_documents: [{"path": "docs/APT_PROVIDER_DETAIL_DESIGN.md", "repository": "/home/omry/dev/reploy", "sha256": "14e7809c811f921168594cf40aae0c2e9d480016352d59d74fcaa8030c12bdda"}] -baseline_documents: [{"path": "docs/APT_PROVIDER.md", "repository": "/home/omry/dev/reploy", "sha256": "281e4c65fc5e3e1bd1584ad2543f6388e631ab364b9b00c7630e342dacdae323"}, {"path": "docs/BLUEPRINT_ENVIRONMENT_MODEL.md", "repository": "/home/omry/dev/reploy", "sha256": "8969ffde4d2f20d2e02fb530d0e9688ed63d9dd8962a9a700473d4a6a54e7ef4"}] +baseline_documents: [{"path": "docs/APT_PROVIDER.md", "repository": "/home/omry/dev/reploy", "sha256": "be2008134ae557cf275893325fcd7caae7588ced609c5dc05bf142ad0a6aa60c"}, {"path": "docs/BLUEPRINT_ENVIRONMENT_MODEL.md", "repository": "/home/omry/dev/reploy", "sha256": "8969ffde4d2f20d2e02fb530d0e9688ed63d9dd8962a9a700473d4a6a54e7ef4"}] document_repository: "/home/omry/dev/reploy" document_path: "docs/APT_PROVIDER_DETAIL_DESIGN.md" -document_revision_provenance: "c68e86950815a95fb257f216d3a5183eaf017461" +document_revision_provenance: "a13e17402b578d5f6c6922ac82773ad2f116237f" document_sha256: 14e7809c811f921168594cf40aae0c2e9d480016352d59d74fcaa8030c12bdda verdict: clean -attested_at: 2026-08-22T13:28:02Z +attested_at: 2026-08-22T19:52:51Z --- diff --git a/docs/APT_PROVIDER.md b/docs/APT_PROVIDER.md index a9ff99e8..88d7b8fa 100644 --- a/docs/APT_PROVIDER.md +++ b/docs/APT_PROVIDER.md @@ -337,12 +337,16 @@ match. When a staging package override selects local Python source, its filesystem path is only a local build input. Reploy observes an immutable path-free input -digest while withholding VCS metadata, then lets the declared Python build -backend define the package boundary by producing an sdist. The backend sees -ordinary generated directories and caches; Reploy does not decide package -contents with a generic ignore list. Reploy validates and retains exactly one -closed `.tar.gz` sdist, securely extracts that retained artifact, and builds -the wheel only from the extraction. +digest while withholding VCS metadata and any exact relative subtrees listed +by the selected override's optional `exclude` array, then lets the declared +Python build backend define the package boundary by producing an sdist. +Exclusions are literal forward-slash paths, not glob or ignore-file patterns; +they are applied before Reploy reads metadata beneath the selected path and are +part of source-input identity. Selected FIFOs and other unsupported special +files remain errors. Apart from these explicit input exclusions, the backend +sees ordinary generated directories and caches. Reploy validates and retains +exactly one closed `.tar.gz` sdist, securely extracts that retained artifact, +and builds the wheel only from the extraction. A selected snapshot may contain project-owned `.reploy.yaml` build metadata. The initial strict recipe declares either `pep517` or `setuptools-legacy` and diff --git a/docs/REDESIGN_EVALUATION.md b/docs/REDESIGN_EVALUATION.md index 6d7a2666..fcea552f 100644 --- a/docs/REDESIGN_EVALUATION.md +++ b/docs/REDESIGN_EVALUATION.md @@ -310,9 +310,12 @@ explicit opt-in later. **Approved implementation contract:** - Reploy observes and copies an immutable projection containing every selected - local-source entry except VCS metadata. Generated environments, caches, and - other development files remain visible to the declared build backend; the - backend alone decides whether they enter the sdist. + local-source entry except VCS metadata and exact subtrees explicitly excluded + by the local override. Exclusions are literal input-selection paths, are + applied before entry metadata is read, and participate in source identity. + Other generated environments, caches, and development files remain visible + to the declared build backend; the backend alone decides whether they enter + the sdist. - Every explicit image build with a selected local Python project performs the complete source projection, sdist build, validation, and wheel build before deciding whether later provider layers or the final environment image are @@ -721,11 +724,12 @@ the source manifest: - The virtual environment's Python symlink resolves to the system interpreter, correctly triggering the escape check. -Slice 12 replaced that generic snapshot boundary with a complete provisional -input (excluding VCS metadata), followed by backend-defined sdist creation and -Reploy-owned sdist validation. Generated development state may be visible to -the backend but does not enter the retained artifact unless the project's -packaging metadata selects it. +Slice 12 replaced that generic snapshot boundary with a provisional input, +followed by backend-defined sdist creation and Reploy-owned sdist validation. +The input excludes VCS metadata and may now exclude exact developer-declared +subtrees. Other generated development state may be visible to the backend but +does not enter the retained artifact unless the project's packaging metadata +selects it. That historical diagnostic also exposed excessive internal context and used the ambiguous phrase `workspace root`. Slice 12 now reports source-input, diff --git a/internal/deploy/package_overrides.go b/internal/deploy/package_overrides.go index 2c6453f3..fee2689f 100644 --- a/internal/deploy/package_overrides.go +++ b/internal/deploy/package_overrides.go @@ -7,7 +7,9 @@ import ( "io" "io/fs" "os" + "path" "path/filepath" + "slices" "sort" "strings" "unicode" @@ -43,8 +45,13 @@ type BaseImageOverrideV1 struct { // PackageOverrideChoiceV1 selects exactly one local source or exact upstream // version. A mapping never requests installation by itself. type PackageOverrideChoiceV1 struct { - Path string `yaml:"path,omitempty"` - Version string `yaml:"version,omitempty"` + Path string `yaml:"path,omitempty"` + Version string `yaml:"version,omitempty"` + Exclude []string `yaml:"exclude,omitempty"` +} + +func (choice PackageOverrideChoiceV1) Empty() bool { + return choice.Path == "" && choice.Version == "" && len(choice.Exclude) == 0 } // ResolvedPackageOverridesV1 contains interpolated, normalized lookup keys and @@ -59,6 +66,7 @@ type ResolvedPackageOverridesV1 struct { type ResolvedPackageOverrideChoiceV1 struct { Path string Version string + Exclude []string } // PackageOverrideIntentV1 is the path-free build input retained in the lock. @@ -78,10 +86,11 @@ type PackageAdditionIntentV1 struct { } type PackageOverrideIntentChoiceV1 struct { - Provider string `json:"provider"` - Package string `json:"package"` - Kind string `json:"kind"` - Version string `json:"version"` + Provider string `json:"provider"` + Package string `json:"package"` + Kind string `json:"kind"` + Version string `json:"version"` + Exclude []string `json:"exclude,omitempty"` } func EmptyPackageOverridesV1(environmentID string) PackageOverridesV1 { @@ -118,6 +127,7 @@ func EncodePackageOverridesV1(overrides PackageOverridesV1) ([]byte, error) { if err := ValidatePackageOverridesV1(overrides); err != nil { return nil, err } + overrides = canonicalPackageOverridesV1(overrides) content, err := yaml.Marshal(overrides) if err != nil { return nil, fmt.Errorf("encode package overrides: %w", err) @@ -125,6 +135,24 @@ func EncodePackageOverridesV1(overrides PackageOverridesV1) ([]byte, error) { return content, nil } +// canonicalPackageOverridesV1 returns an encoding copy whose semantically +// unordered exclusion lists use their stable lexical order. Validation must +// run first; copying the nested mappings avoids mutating caller-owned values. +func canonicalPackageOverridesV1(overrides PackageOverridesV1) PackageOverridesV1 { + canonicalOverrides := make(map[string]map[string]PackageOverrideChoiceV1, len(overrides.Environment.PackageOverrides)) + for provider, packages := range overrides.Environment.PackageOverrides { + canonicalPackages := make(map[string]PackageOverrideChoiceV1, len(packages)) + for packageID, choice := range packages { + exclusions, _ := NormalizePackageOverrideExclusionsV1(choice.Exclude) + choice.Exclude = exclusions + canonicalPackages[packageID] = choice + } + canonicalOverrides[provider] = canonicalPackages + } + overrides.Environment.PackageOverrides = canonicalOverrides + return overrides +} + func ValidatePackageOverridesV1(overrides PackageOverridesV1) error { environment := overrides.Environment if err := blueprint.ValidateEnvironmentID("package overrides environment.id", environment.ID); err != nil { @@ -202,11 +230,46 @@ func ValidatePackageOverridesV1(overrides PackageOverridesV1) error { if version != "" && (strings.HasPrefix(version, "-") || containsControl(version)) { return fmt.Errorf("package override %s.%s version must be plain version text", provider, packageID) } + exclusions, err := NormalizePackageOverrideExclusionsV1(choice.Exclude) + if err != nil { + return fmt.Errorf("package override %s.%s exclude: %w", provider, packageID, err) + } + if len(exclusions) != 0 && pathValue == "" { + return fmt.Errorf("package override %s.%s exclude requires a local path", provider, packageID) + } } } return nil } +// NormalizePackageOverrideExclusionsV1 validates exact source-relative paths +// and returns their stable lexical order. Entries select the named path and +// its descendants; they are deliberately not glob or ignore-file patterns. +func NormalizePackageOverrideExclusionsV1(exclusions []string) ([]string, error) { + normalized := append([]string{}, exclusions...) + for index, exclusion := range normalized { + if exclusion == "" || strings.TrimSpace(exclusion) != exclusion || + !utf8.ValidString(exclusion) || containsControl(exclusion) || + path.IsAbs(exclusion) || path.Clean(exclusion) != exclusion || + strings.ContainsAny(exclusion, `\:*?[]`) || exclusion == "." || + exclusion == ".." || strings.HasPrefix(exclusion, "../") { + return nil, fmt.Errorf("entry %d must be a canonical relative path using forward slashes", index) + } + for _, component := range strings.Split(exclusion, "/") { + if component == "" || component == "." || component == ".." { + return nil, fmt.Errorf("entry %d must be a canonical relative path using forward slashes", index) + } + } + } + sort.Strings(normalized) + for index := 1; index < len(normalized); index++ { + if normalized[index-1] == normalized[index] { + return nil, fmt.Errorf("contains duplicate path %q", normalized[index]) + } + } + return normalized, nil +} + // NormalizePackageAdditionV1 validates a provider-native development package // addition without translating its package name. The os provider currently // selects the Debian/Ubuntu APT implementation at build time. @@ -355,7 +418,14 @@ func ResolvePackageOverridesV1( } owners[normalized] = packageID - resolvedChoice := ResolvedPackageOverrideChoiceV1{Version: choice.Version} + exclusions, err := NormalizePackageOverrideExclusionsV1(choice.Exclude) + if err != nil { + return ResolvedPackageOverridesV1{}, fmt.Errorf("package override %s.%s exclude: %w", provider, packageID, err) + } + resolvedChoice := ResolvedPackageOverrideChoiceV1{ + Version: choice.Version, + Exclude: exclusions, + } if choice.Path != "" { interpolated, err := blueprint.ResolveEnvironmentVariableString(choice.Path, variables) if err != nil { @@ -420,6 +490,7 @@ func (overrides ResolvedPackageOverridesV1) Intent() (PackageOverrideIntentV1, e switch { case choice.Path != "" && choice.Version == "": item.Kind = "local" + item.Exclude = append([]string{}, choice.Exclude...) case choice.Path == "" && choice.Version != "": item.Kind = "version" item.Version = choice.Version @@ -486,10 +557,20 @@ func ValidatePackageOverrideIntentV1(intent PackageOverrideIntentV1) error { if choice.Version != "" { return fmt.Errorf("local package override intent %s.%s must not contain a version", choice.Provider, choice.Package) } + exclusions, err := NormalizePackageOverrideExclusionsV1(choice.Exclude) + if err != nil { + return fmt.Errorf("local package override intent %s.%s exclude: %w", choice.Provider, choice.Package, err) + } + if !slices.Equal(exclusions, choice.Exclude) { + return fmt.Errorf("local package override intent %s.%s exclusions must be unique and sorted", choice.Provider, choice.Package) + } case "version": if choice.Version == "" || strings.HasPrefix(choice.Version, "-") || containsControl(choice.Version) { return fmt.Errorf("version package override intent %s.%s must contain plain version text", choice.Provider, choice.Package) } + if len(choice.Exclude) != 0 { + return fmt.Errorf("version package override intent %s.%s must not contain exclusions", choice.Provider, choice.Package) + } default: return fmt.Errorf("package override intent %s.%s has unsupported kind %q", choice.Provider, choice.Package, choice.Kind) } diff --git a/internal/deploy/package_overrides_test.go b/internal/deploy/package_overrides_test.go index 291875cd..f1f6feee 100644 --- a/internal/deploy/package_overrides_test.go +++ b/internal/deploy/package_overrides_test.go @@ -23,8 +23,11 @@ func TestPackageOverridesRoundTripAndResolve(t *testing.T) { }, PackageOverrides: map[string]map[string]PackageOverrideChoiceV1{ "python": { - "Demo_Pkg": {Path: "{{ workspace_root }}/demo"}, - "other": {Version: "2.4.0"}, + "Demo_Pkg": { + Path: "{{ workspace_root }}/demo", + Exclude: []string{"recordings/.omegaflow", ".venv"}, + }, + "other": {Version: "2.4.0"}, }, }, }} @@ -51,6 +54,9 @@ func TestPackageOverridesRoundTripAndResolve(t *testing.T) { if got := resolved.Providers["python"]["demo-pkg"].Path; got != filepath.Join(dir, "workspace", "demo") { t.Fatalf("resolved local path = %q", got) } + if got := resolved.Providers["python"]["demo-pkg"].Exclude; len(got) != 2 || got[0] != ".venv" || got[1] != "recordings/.omegaflow" { + t.Fatalf("resolved exclusions = %#v", got) + } if got := resolved.Providers["python"]["other"].Version; got != "2.4.0" { t.Fatalf("resolved version = %q", got) } @@ -62,8 +68,11 @@ func TestPackageOverridesRoundTripAndResolve(t *testing.T) { t.Fatal(err) } if len(intent.Choices) != 2 || - intent.Choices[0] != (PackageOverrideIntentChoiceV1{Provider: "python", Package: "demo-pkg", Kind: "local"}) || - intent.Choices[1] != (PackageOverrideIntentChoiceV1{Provider: "python", Package: "other", Kind: "version", Version: "2.4.0"}) { + intent.Choices[0].Provider != "python" || intent.Choices[0].Package != "demo-pkg" || + intent.Choices[0].Kind != "local" || len(intent.Choices[0].Exclude) != 2 || + intent.Choices[0].Exclude[0] != ".venv" || intent.Choices[0].Exclude[1] != "recordings/.omegaflow" || + intent.Choices[1].Provider != "python" || intent.Choices[1].Package != "other" || + intent.Choices[1].Kind != "version" || intent.Choices[1].Version != "2.4.0" { t.Fatalf("intent = %#v", intent) } if len(intent.Additions) != 1 || @@ -237,6 +246,11 @@ func TestPackageOverridesRejectInvalidShape(t *testing.T) { {name: "multiple documents", yaml: "environment:\n id: demo\n package_overrides: {}\n---\n{}\n", want: "multiple YAML documents"}, {name: "both choices", yaml: "environment:\n id: demo\n package_overrides:\n python:\n demo: {path: ../demo, version: 1.0}\n", want: "exactly one"}, {name: "neither choice", yaml: "environment:\n id: demo\n package_overrides:\n python:\n demo: {}\n", want: "exactly one"}, + {name: "exclude on version", yaml: "environment:\n id: demo\n package_overrides:\n python:\n demo: {version: 1.0, exclude: [recordings]}\n", want: "requires a local path"}, + {name: "absolute exclude", yaml: "environment:\n id: demo\n package_overrides:\n python:\n demo: {path: ../demo, exclude: [/recordings]}\n", want: "canonical relative path"}, + {name: "escaping exclude", yaml: "environment:\n id: demo\n package_overrides:\n python:\n demo: {path: ../demo, exclude: [../recordings]}\n", want: "canonical relative path"}, + {name: "glob exclude", yaml: "environment:\n id: demo\n package_overrides:\n python:\n demo: {path: ../demo, exclude: ['recordings/*']}\n", want: "canonical relative path"}, + {name: "duplicate exclude", yaml: "environment:\n id: demo\n package_overrides:\n python:\n demo: {path: ../demo, exclude: [recordings, recordings]}\n", want: "duplicate path"}, {name: "missing mappings", yaml: "environment:\n id: demo\n", want: "package_overrides must use a mapping"}, {name: "variable cycle", yaml: "environment:\n id: demo\n vars: {a: '{{ b }}', b: '{{ a }}'}\n package_overrides: {}\n", want: "cycle"}, {name: "unsupported addition provider", yaml: "environment:\n id: demo\n package_additions: {apt: [default-jre-headless]}\n package_overrides: {}\n", want: "use os"}, diff --git a/internal/deploy/validated_build.go b/internal/deploy/validated_build.go index f0fa724b..cb5510ae 100644 --- a/internal/deploy/validated_build.go +++ b/internal/deploy/validated_build.go @@ -52,8 +52,9 @@ func PackageOverridesDigestV1(overrides PackageOverridesV1) (canonical.Digest, e return "", err } // EncodePackageOverridesV1 normalizes YAML map ordering, scalar spelling, - // and omitted optional fields. Hash that normalized representation so every - // value accepted by the sidecar schema has a stable identity. + // exclusion ordering, and omitted optional fields. Hash that normalized + // representation so every value accepted by the sidecar schema has a stable + // identity. return canonical.Sum("package-overrides", "package-overrides-v1", string(content)) } diff --git a/internal/deploy/validated_build_test.go b/internal/deploy/validated_build_test.go index 93c9b827..d859d105 100644 --- a/internal/deploy/validated_build_test.go +++ b/internal/deploy/validated_build_test.go @@ -122,6 +122,32 @@ func TestPackageOverridesDigestV1NormalizesEmptyOptionalVars(t *testing.T) { } } +func TestPackageOverridesDigestV1NormalizesExclusionOrder(t *testing.T) { + first := EmptyPackageOverridesV1("demo") + first.Environment.PackageOverrides["python"] = map[string]PackageOverrideChoiceV1{ + "demo": {Path: "../demo", Exclude: []string{"recordings/.omegaflow", ".venv"}}, + } + second := EmptyPackageOverridesV1("demo") + second.Environment.PackageOverrides["python"] = map[string]PackageOverrideChoiceV1{ + "demo": {Path: "../demo", Exclude: []string{".venv", "recordings/.omegaflow"}}, + } + + firstDigest, err := PackageOverridesDigestV1(first) + if err != nil { + t.Fatal(err) + } + secondDigest, err := PackageOverridesDigestV1(second) + if err != nil { + t.Fatal(err) + } + if firstDigest != secondDigest { + t.Fatalf("equivalent exclusion orders have different digests: %s != %s", firstDigest, secondDigest) + } + if got := first.Environment.PackageOverrides["python"]["demo"].Exclude; !reflect.DeepEqual(got, []string{"recordings/.omegaflow", ".venv"}) { + t.Fatalf("digest mutated caller exclusions: %#v", got) + } +} + func TestValidateValidatedBuildV1RejectsUnsafeImageReference(t *testing.T) { platform, err := blueprint.ParsePlatform("linux/amd64") if err != nil { diff --git a/internal/dockerdeploy/pack_stage_v1.go b/internal/dockerdeploy/pack_stage_v1.go index 7125aebc..c0308c46 100644 --- a/internal/dockerdeploy/pack_stage_v1.go +++ b/internal/dockerdeploy/pack_stage_v1.go @@ -186,6 +186,7 @@ func localBlueprintInitialPackageOverridesV1( } stagedPackages[packageID] = deploy.PackageOverrideChoiceV1{ Path: path, Version: choice.Version, + Exclude: append([]string{}, choice.Exclude...), } } staged.Environment.PackageOverrides[provider] = stagedPackages diff --git a/internal/dockerdeploy/pack_stage_v1_test.go b/internal/dockerdeploy/pack_stage_v1_test.go index d65d0883..5422d8ea 100644 --- a/internal/dockerdeploy/pack_stage_v1_test.go +++ b/internal/dockerdeploy/pack_stage_v1_test.go @@ -128,6 +128,8 @@ func TestStagePackDesiredStateV1ImportsLocalBlueprintSidecarOnCreate(t *testing. python: omegaconf-inspector: path: "{{ workspace_root }}/checkout" + exclude: + - recordings/.omegaflow `) if err := os.WriteFile(filepath.Join(sourceDir, deploy.PackageOverridesFilename), sidecar, 0o600); err != nil { t.Fatal(err) @@ -149,6 +151,9 @@ func TestStagePackDesiredStateV1ImportsLocalBlueprintSidecarOnCreate(t *testing. if choice.Path != "{{ workspace_root }}/checkout" { t.Fatalf("staged path = %q", choice.Path) } + if !reflect.DeepEqual(choice.Exclude, []string{"recordings/.omegaflow"}) { + t.Fatalf("staged exclusions = %#v", choice.Exclude) + } if got := staged.Environment.Vars["workspace_root"]; got != filepath.Dir(localProject) { t.Fatalf("staged workspace_root = %#v, want %q", got, filepath.Dir(localProject)) } diff --git a/internal/dockerdeploy/python_local_sources.go b/internal/dockerdeploy/python_local_sources.go index 489d539b..765d6477 100644 --- a/internal/dockerdeploy/python_local_sources.go +++ b/internal/dockerdeploy/python_local_sources.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "slices" "sort" "strings" "unicode/utf8" @@ -18,13 +19,14 @@ import ( const pythonSourceManifestSchemaV1 = pythonprovider.SourceInputManifestSchemaV1 -// PythonLocalOverrideV1 is an uninterpreted, staging-only physical locator. -// Constructing it never accesses HostDir; source observation is demand-driven -// after a direct request or resolved package closure identifies a matching -// distribution. +// PythonLocalOverrideV1 is a staging-only physical locator plus its normalized +// input exclusions. Constructing it never accesses HostDir; source observation +// is demand-driven after a direct request or resolved package closure identifies +// a matching distribution. type PythonLocalOverrideV1 struct { Distribution string HostDir string + Exclude []string } // PythonLocalSource is a staging-only physical locator paired with the @@ -40,6 +42,7 @@ type PythonLocalSource struct { type PythonSourceManifestV1 struct { Schema string `json:"schema"` + Exclude []string `json:"exclude"` Entries []PythonSourceManifestEntryV1 `json:"entries"` } @@ -71,7 +74,11 @@ func PythonLocalOverridesV1( if !filepath.IsAbs(choice.Path) || filepath.Clean(choice.Path) != choice.Path { return nil, fmt.Errorf("local Python override %q path must be absolute and clean", distribution) } - result = append(result, PythonLocalOverrideV1{Distribution: distribution, HostDir: choice.Path}) + result = append(result, PythonLocalOverrideV1{ + Distribution: distribution, + HostDir: choice.Path, + Exclude: append([]string{}, choice.Exclude...), + }) } sort.Slice(result, func(left int, right int) bool { return result[left].Distribution < result[right].Distribution @@ -85,10 +92,10 @@ func ObserveSelectedPythonLocalSources( overrides []PythonLocalOverrideV1, distributions []string, ) ([]PythonLocalSource, error) { - return observeSelectedPythonLocalSources(overrides, distributions, ObservePythonSourceManifest) + return observeSelectedPythonLocalSources(overrides, distributions, ObservePythonSourceManifestWithExclusions) } -type pythonSourceManifestObserver func(string) (PythonSourceManifestV1, canonical.Digest, error) +type pythonSourceManifestObserver func(string, []string) (PythonSourceManifestV1, canonical.Digest, error) func observeSelectedPythonLocalSources( overrides []PythonLocalOverrideV1, @@ -126,6 +133,10 @@ func observeSelectedPythonLocalSources( if override.HostDir == "" || !filepath.IsAbs(override.HostDir) || filepath.Clean(override.HostDir) != override.HostDir { return nil, fmt.Errorf("local Python override %q path must be absolute and clean", override.Distribution) } + exclusions, err := deploy.NormalizePackageOverrideExclusionsV1(override.Exclude) + if err != nil || !slices.Equal(exclusions, override.Exclude) { + return nil, fmt.Errorf("local Python override %q exclusions must be canonical, unique, and sorted", override.Distribution) + } if _, found := selected[override.Distribution]; !found { continue } @@ -133,7 +144,7 @@ func observeSelectedPythonLocalSources( if err != nil { return nil, fmt.Errorf("local Python override %q source: %w", override.Distribution, err) } - manifest, digest, err := observe(hostDir) + manifest, digest, err := observe(hostDir, override.Exclude) if err != nil { return nil, fmt.Errorf("local Python override %q source input: %w", override.Distribution, err) } @@ -147,14 +158,29 @@ func observeSelectedPythonLocalSources( // ObservePythonSourceManifest records the complete immutable input exposed to // the build backend, excluding only repository metadata that v1 deliberately -// withholds. Packaging metadata, not a Reploy ignore list, defines the sdist. +// withholds. func ObservePythonSourceManifest(sourceDir string) (PythonSourceManifestV1, canonical.Digest, error) { + return ObservePythonSourceManifestWithExclusions(sourceDir, []string{}) +} + +// ObservePythonSourceManifestWithExclusions additionally withholds exact +// source-relative paths and their descendants before their metadata is read. +// Packaging metadata still defines the retained sdist from the selected input. +func ObservePythonSourceManifestWithExclusions( + sourceDir string, + exclusions []string, +) (PythonSourceManifestV1, canonical.Digest, error) { realSource, err := resolveRealPythonSourceDirectory(sourceDir) if err != nil { return PythonSourceManifestV1{}, "", err } + normalizedExclusions, err := deploy.NormalizePackageOverrideExclusionsV1(exclusions) + if err != nil { + return PythonSourceManifestV1{}, "", fmt.Errorf("source exclusions: %w", err) + } manifest := PythonSourceManifestV1{ Schema: pythonSourceManifestSchemaV1, + Exclude: normalizedExclusions, Entries: []PythonSourceManifestEntryV1{}, } err = filepath.WalkDir(realSource, func(filename string, entry os.DirEntry, walkErr error) error { @@ -164,6 +190,17 @@ func ObservePythonSourceManifest(sourceDir string) (PythonSourceManifestV1, cano if filename == realSource { return nil } + relative, err := filepath.Rel(realSource, filename) + if err != nil { + return err + } + relative = filepath.ToSlash(relative) + if excludedPythonSourceEntry(relative, normalizedExclusions) { + if entry.IsDir() { + return filepath.SkipDir + } + return nil + } name := entry.Name() if ignoredPythonSourceEntry(name) { if entry.IsDir() { @@ -171,11 +208,7 @@ func ObservePythonSourceManifest(sourceDir string) (PythonSourceManifestV1, cano } return nil } - relative, err := filepath.Rel(realSource, filename) - if err != nil { - return err - } - manifestEntry := PythonSourceManifestEntryV1{Path: filepath.ToSlash(relative)} + manifestEntry := PythonSourceManifestEntryV1{Path: relative} info, err := entry.Info() if err != nil { return err @@ -228,6 +261,15 @@ func ObservePythonSourceManifest(sourceDir string) (PythonSourceManifestV1, cano return manifest, digest, nil } +func excludedPythonSourceEntry(relative string, exclusions []string) bool { + for _, exclusion := range exclusions { + if relative == exclusion || strings.HasPrefix(relative, exclusion+"/") { + return true + } + } + return false +} + func resolveRealPythonSourceDirectory(directory string) (string, error) { absolute, err := filepath.Abs(directory) if err != nil { diff --git a/internal/dockerdeploy/python_local_sources_posix_test.go b/internal/dockerdeploy/python_local_sources_posix_test.go new file mode 100644 index 00000000..417aa713 --- /dev/null +++ b/internal/dockerdeploy/python_local_sources_posix_test.go @@ -0,0 +1,42 @@ +//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris + +package dockerdeploy + +import ( + "os" + "path/filepath" + "strings" + "syscall" + "testing" +) + +func TestObservePythonSourceManifestExcludesFIFOWithoutInspectingIt(t *testing.T) { + sourceDir := t.TempDir() + if err := os.WriteFile(filepath.Join(sourceDir, "pyproject.toml"), []byte("[build-system]\n"), 0o644); err != nil { + t.Fatal(err) + } + generated := filepath.Join(sourceDir, "recordings", ".omegaflow", "run") + if err := os.MkdirAll(generated, 0o755); err != nil { + t.Fatal(err) + } + fifo := filepath.Join(generated, "input.pipe") + if err := syscall.Mkfifo(fifo, 0o600); err != nil { + t.Fatal(err) + } + + manifest, _, err := ObservePythonSourceManifestWithExclusions( + sourceDir, []string{"recordings/.omegaflow"}, + ) + if err != nil { + t.Fatal(err) + } + for _, entry := range manifest.Entries { + if strings.HasPrefix(entry.Path, "recordings/.omegaflow") { + t.Fatalf("excluded FIFO subtree entered source manifest: %#v", entry) + } + } + if _, _, err := ObservePythonSourceManifest(sourceDir); err == nil || + !strings.Contains(err.Error(), "unsupported file type") { + t.Fatalf("selected FIFO error = %v", err) + } +} diff --git a/internal/dockerdeploy/python_local_sources_test.go b/internal/dockerdeploy/python_local_sources_test.go index 00970bae..da2a62c3 100644 --- a/internal/dockerdeploy/python_local_sources_test.go +++ b/internal/dockerdeploy/python_local_sources_test.go @@ -17,7 +17,7 @@ func TestPythonLocalOverridesV1ExtractsSortedLocatorsWithoutFilesystemReads(t *t Providers: map[string]map[string]deploy.ResolvedPackageOverrideChoiceV1{ "python": { "other": {Version: "2.0"}, - "demo-pkg": {Path: missing}, + "demo-pkg": {Path: missing, Exclude: []string{"recordings/.omegaflow"}}, }, }, } @@ -25,7 +25,10 @@ func TestPythonLocalOverridesV1ExtractsSortedLocatorsWithoutFilesystemReads(t *t if err != nil { t.Fatal(err) } - want := []PythonLocalOverrideV1{{Distribution: "demo-pkg", HostDir: missing}} + want := []PythonLocalOverrideV1{{ + Distribution: "demo-pkg", HostDir: missing, + Exclude: []string{"recordings/.omegaflow"}, + }} if !reflect.DeepEqual(overrides, want) { t.Fatalf("local overrides = %#v, want %#v", overrides, want) } @@ -40,8 +43,14 @@ func TestObserveSelectedPythonLocalSourcesDoesNotReadUnselectedPaths(t *testing. if err := os.WriteFile(filepath.Join(selected, "pyproject.toml"), []byte("[project]\nname='demo'\nversion='1.0'\n"), 0o644); err != nil { t.Fatal(err) } + if err := os.Mkdir(filepath.Join(selected, "generated"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(selected, "generated", "state"), []byte("state"), 0o644); err != nil { + t.Fatal(err) + } overrides := []PythonLocalOverrideV1{ - {Distribution: "demo", HostDir: selected}, + {Distribution: "demo", HostDir: selected, Exclude: []string{"generated"}}, {Distribution: "unused", HostDir: filepath.Join(root, "missing")}, } sources, err := ObserveSelectedPythonLocalSources(overrides, []string{"demo"}) @@ -49,9 +58,15 @@ func TestObserveSelectedPythonLocalSourcesDoesNotReadUnselectedPaths(t *testing. t.Fatal(err) } if len(sources) != 1 || sources[0].Distribution != "demo" || - sources[0].HostDir != selected || len(sources[0].Manifest.Entries) == 0 { + sources[0].HostDir != selected || len(sources[0].Manifest.Entries) == 0 || + !reflect.DeepEqual(sources[0].Manifest.Exclude, []string{"generated"}) { t.Fatalf("local sources = %#v", sources) } + for _, entry := range sources[0].Manifest.Entries { + if entry.Path == "generated" || strings.HasPrefix(entry.Path, "generated/") { + t.Fatalf("selected source retained excluded path: %#v", entry) + } + } } func TestObserveSelectedPythonLocalSourcesBindsCurrentContent(t *testing.T) { @@ -177,3 +192,51 @@ func TestObservePythonSourceManifestCanonicalizesDepthFirstTraversal(t *testing. t.Fatalf("manifest paths = %#v, want %#v", got, want) } } + +func TestObservePythonSourceManifestAppliesExactExclusionsAndBindsIntent(t *testing.T) { + sourceDir := t.TempDir() + for name, content := range map[string]string{ + "pyproject.toml": "[build-system]\n", + "recordings/keep.txt": "keep\n", + "recordings/.omegaflow/state.json": "generated\n", + } { + filename := filepath.Join(sourceDir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(filename), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filename, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + manifest, digest, err := ObservePythonSourceManifestWithExclusions( + sourceDir, []string{"recordings/.omegaflow"}, + ) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(manifest.Exclude, []string{"recordings/.omegaflow"}) { + t.Fatalf("manifest exclusions = %#v", manifest.Exclude) + } + for _, entry := range manifest.Entries { + if entry.Path == "recordings/.omegaflow" || strings.HasPrefix(entry.Path, "recordings/.omegaflow/") { + t.Fatalf("excluded source entry was observed: %#v", entry) + } + } + withoutIntent, withoutIntentDigest, err := ObservePythonSourceManifestWithExclusions( + sourceDir, []string{"missing-generated-state"}, + ) + if err != nil { + t.Fatal(err) + } + baseline, baselineDigest, err := ObservePythonSourceManifest(sourceDir) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(withoutIntent.Entries, baseline.Entries) { + t.Fatalf("nonexistent exclusion changed selected entries: %#v / %#v", withoutIntent.Entries, baseline.Entries) + } + if withoutIntentDigest == baselineDigest || digest == baselineDigest { + t.Fatal("source exclusion intent did not participate in the source-input digest") + } +} diff --git a/internal/dockerdeploy/python_resolver_wheels_integration_test.go b/internal/dockerdeploy/python_resolver_wheels_integration_test.go index bf9fa28e..2ca98c9a 100644 --- a/internal/dockerdeploy/python_resolver_wheels_integration_test.go +++ b/internal/dockerdeploy/python_resolver_wheels_integration_test.go @@ -152,8 +152,8 @@ func TestPythonLocalSourceResolverIntegration(t *testing.T) { t.Fatal(err) } pyproject := `[build-system] -requires = ["setuptools>=77"] -build-backend = "setuptools.build_meta" +requires = ["hatchling"] +build-backend = "hatchling.build" [project] name = "demo-server" @@ -177,7 +177,9 @@ demo-server = "demo_server:main" ); err != nil { t.Fatal(err) } - manifest, digest, err := ObservePythonSourceManifest(sourceDir) + manifest, digest, err := ObservePythonSourceManifestWithExclusions( + sourceDir, []string{".venv-demo"}, + ) if err != nil { t.Fatal(err) } @@ -189,6 +191,9 @@ demo-server = "demo_server:main" if err != nil { t.Fatal(err) } + if _, err := os.Lstat(filepath.Join(snapshots[0].HostDir, ".venv-demo")); !os.IsNotExist(err) { + t.Fatalf("excluded .venv-demo entered immutable source snapshot: %v", err) + } probeWorkspace := buildIntegrationProbeWorkspace(t, platform) session, err := OpenPythonResolverSession(ctx, descriptor, probeWorkspace, artifacts) if err != nil { diff --git a/internal/dockerdeploy/python_source_snapshots.go b/internal/dockerdeploy/python_source_snapshots.go index 312ffa71..c7bfed86 100644 --- a/internal/dockerdeploy/python_source_snapshots.go +++ b/internal/dockerdeploy/python_source_snapshots.go @@ -9,12 +9,14 @@ import ( "path" "path/filepath" "reflect" + "slices" "strconv" "strings" "unicode/utf8" "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/canonical" + "github.com/omry/reploy/internal/deploy" pythonprovider "github.com/omry/reploy/internal/providers/python" ) @@ -161,6 +163,16 @@ func validatePythonSourceManifestV1(manifest PythonSourceManifestV1) error { if manifest.Entries == nil { return fmt.Errorf("entries must use an array") } + if manifest.Exclude == nil { + return fmt.Errorf("exclude must use an array") + } + exclusions, err := deploy.NormalizePackageOverrideExclusionsV1(manifest.Exclude) + if err != nil { + return fmt.Errorf("exclusions: %w", err) + } + if !slices.Equal(exclusions, manifest.Exclude) { + return fmt.Errorf("exclusions must be unique and sorted") + } directories := map[string]struct{}{".": {}} for index, entry := range manifest.Entries { if entry.Path == "" || entry.Path == "." || path.IsAbs(entry.Path) || path.Clean(entry.Path) != entry.Path || @@ -279,7 +291,7 @@ func stageOnePythonSourceSnapshot(source PythonLocalSource, destination string) if err := os.Chmod(destination, 0o555); err != nil { return err } - observed, digest, err := ObservePythonSourceManifest(destination) + observed, digest, err := ObservePythonSourceManifestWithExclusions(destination, source.Manifest.Exclude) if err != nil { return err } diff --git a/internal/dockerdeploy/python_source_snapshots_test.go b/internal/dockerdeploy/python_source_snapshots_test.go index 5dc8ac0c..8466b3d5 100644 --- a/internal/dockerdeploy/python_source_snapshots_test.go +++ b/internal/dockerdeploy/python_source_snapshots_test.go @@ -86,7 +86,8 @@ func TestStagePythonLocalSourceSnapshotsCopiesOnlyManifestEntries(t *testing.T) func TestStagePythonLocalSourceSnapshotsExplainsInvalidManifestOrder(t *testing.T) { sourceDir := t.TempDir() manifest := PythonSourceManifestV1{ - Schema: pythonSourceManifestSchemaV1, + Schema: pythonSourceManifestSchemaV1, + Exclude: []string{}, Entries: []PythonSourceManifestEntryV1{ {Path: "zeta", Kind: "directory", Mode: "0755"}, {Path: "alpha", Kind: "directory", Mode: "0755"}, diff --git a/internal/overrideui/editor.go b/internal/overrideui/editor.go index 9173b20f..99c8b812 100644 --- a/internal/overrideui/editor.go +++ b/internal/overrideui/editor.go @@ -1024,7 +1024,11 @@ func (m *model) viewMain() string { source = item.Provider + " provider" } if item.Choice.Path != "" { - source = "local · " + item.Choice.Path + if len(item.Choice.Exclude) == 0 { + source = "local · " + item.Choice.Path + } else { + source = fmt.Sprintf("local · %d excluded · %s", len(item.Choice.Exclude), item.Choice.Path) + } } if item.Choice.Version != "" { if item.Provider == "python" { @@ -1099,7 +1103,7 @@ func (m *model) viewMain() string { } func (m *model) isUnusedOverride(item overrideItem) bool { - if item.Choice == (deploy.PackageOverrideChoiceV1{}) { + if item.Choice.Empty() { return false } for _, unused := range m.unusedOverrides { @@ -1484,7 +1488,7 @@ func (m *model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *model) updateChoose(key tea.KeyMsg) (tea.Model, tea.Cmd) { switch key.String() { case "esc": - if !m.current().Explicit && m.current().Choice == (deploy.PackageOverrideChoiceV1{}) { + if !m.current().Explicit && m.current().Choice.Empty() { m.resetCurrentOverride() } m.screen = screenMain @@ -1733,7 +1737,10 @@ func (m *model) updatePath(key tea.KeyMsg) (tea.Model, tea.Cmd) { m.status = "Choose a project directory, enter a path relative to the workspace, or enter an absolute path." return m, nil } - m.items[m.cursor].Choice = deploy.PackageOverrideChoiceV1{Path: storedPath(m.workspaceResolved, selected)} + m.items[m.cursor].Choice = deploy.PackageOverrideChoiceV1{ + Path: storedPath(m.workspaceResolved, selected), + Exclude: append([]string{}, m.items[m.cursor].Choice.Exclude...), + } m.input.Blur() m.screen = screenMain m.dirty = true @@ -2063,7 +2070,7 @@ func (m *model) refreshDiscoveredItems() { item.Sources = append([]string{}, sources...) } } else if item.Discovered { - if item.Choice == (deploy.PackageOverrideChoiceV1{}) { + if item.Choice.Empty() { continue } item.Discovered = false diff --git a/internal/overrideui/editor_test.go b/internal/overrideui/editor_test.go index 008265fd..ba1ef64c 100644 --- a/internal/overrideui/editor_test.go +++ b/internal/overrideui/editor_test.go @@ -29,7 +29,7 @@ func TestNewModelLoadsExistingOverridesAndBlueprintRoots(t *testing.T) { overrides.Environment.Base = &deploy.BaseImageOverrideV1{Image: "python:3.13-slim"} overrides.Environment.Vars["workspace_root"] = filepath.Dir(project) overrides.Environment.PackageOverrides["python"] = map[string]deploy.PackageOverrideChoiceV1{ - "demo": {Path: "{{ workspace_root }}/demo"}, + "demo": {Path: "{{ workspace_root }}/demo", Exclude: []string{"recordings/.omegaflow"}}, "extra": {Version: "2.0"}, } commitOverrides(t, dir, overrides) @@ -54,12 +54,16 @@ func TestNewModelLoadsExistingOverridesAndBlueprintRoots(t *testing.T) { if len(m.items) != 3 { t.Fatalf("items = %#v", m.items) } - if m.items[0].Package != "other" || !m.items[0].Explicit || m.items[0].Choice != (deploy.PackageOverrideChoiceV1{}) { + if m.items[0].Package != "other" || !m.items[0].Explicit || !m.items[0].Choice.Empty() { t.Fatalf("blueprint root = %#v", m.items[0]) } if m.items[1].Package != "demo" || m.items[1].Explicit || m.items[1].Choice.Path == "" { t.Fatalf("demo item = %#v", m.items[1]) } + if !reflect.DeepEqual(m.items[1].Choice.Exclude, []string{"recordings/.omegaflow"}) || + !reflect.DeepEqual(m.buildRaw().Environment.PackageOverrides["python"]["demo"].Exclude, []string{"recordings/.omegaflow"}) { + t.Fatalf("editor did not preserve local-source exclusions: %#v", m.items[1].Choice) + } if m.items[2].Package != "extra" || m.items[2].Explicit || m.items[2].Choice.Version != "2.0" { t.Fatalf("extra item = %#v", m.items[2]) } @@ -628,7 +632,10 @@ func TestMainViewFitsNarrowTerminal(t *testing.T) { m := editorModelForInteraction(t) m.width = 48 m.height = 24 - m.items[0].Choice = deploy.PackageOverrideChoiceV1{Path: "/a/very/long/path/to/a/local/project/that/must/not-overflow"} + m.items[0].Choice = deploy.PackageOverrideChoiceV1{ + Path: "/a/very/long/path/to/a/local/project/that/must/not-overflow", + Exclude: []string{"recordings/.omegaflow"}, + } m.items = append(m.items, overrideItem{ Provider: "python", Package: "a-long-package-name-that-is-only-an-override", @@ -649,17 +656,26 @@ func TestMainViewFitsNarrowTerminal(t *testing.T) { if !strings.Contains(view, "override-only") { t.Fatalf("narrow view lost override-only classification:\n%s", view) } + if !strings.Contains(view, "1 excluded") { + t.Fatalf("narrow view hid the local source exclusion count:\n%s", view) + } } func TestMainViewShowsFullLocalPathWhenTerminalHasRoom(t *testing.T) { m := editorModelForInteraction(t) m.width = 140 path := "/home/omry/dev/reploy/examples/omegaconf-inspector" - m.items[0].Choice = deploy.PackageOverrideChoiceV1{Path: path} + m.items[0].Choice = deploy.PackageOverrideChoiceV1{ + Path: path, + Exclude: []string{"recordings/.omegaflow"}, + } view := m.View() - if !strings.Contains(view, "local · "+path) { + if !strings.Contains(view, path) { t.Fatalf("wide view truncated the local source path:\n%s", view) } + if !strings.Contains(view, "1 excluded") { + t.Fatalf("wide view hid the local source exclusion count:\n%s", view) + } } func TestProjectResultsDoNotRenderTerminalControls(t *testing.T) { @@ -899,7 +915,7 @@ func TestResetRemovesOverrideOnlyRowButRetainsExplicitDependency(t *testing.T) { m.cursor = 0 updated, _ = m.updateKey(tea.KeyMsg{Type: tea.KeyDelete}) m = updated.(*model) - if len(m.items) != 1 || m.items[0].Choice != (deploy.PackageOverrideChoiceV1{}) { + if len(m.items) != 1 || !m.items[0].Choice.Empty() { t.Fatalf("reset explicit items = %#v", m.items) } } @@ -1082,6 +1098,25 @@ func TestLocalSourceEditorPrefillsWorkspaceRelativeSelection(t *testing.T) { } } +func TestLocalSourceEditorPreservesExclusionsWhenReselectingPath(t *testing.T) { + workspace := t.TempDir() + project := pythonProject(t, filepath.Join(workspace, "demo"), "demo") + m := editorModelForInteraction(t) + m.workspaceResolved = workspace + m.items[0].Choice = deploy.PackageOverrideChoiceV1{ + Path: "{{ workspace_root }}/demo", + Exclude: []string{"recordings/.omegaflow"}, + } + m.screen = screenPath + m.input.SetValue(project) + m.results = []string{} + updated, _ := m.updatePath(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(*model) + if !reflect.DeepEqual(m.items[0].Choice.Exclude, []string{"recordings/.omegaflow"}) { + t.Fatalf("reselected local path dropped exclusions: %#v", m.items[0].Choice) + } +} + func TestMatchingProjectsDoesNotSilentlyTruncateWorkspace(t *testing.T) { root := t.TempDir() for index := range 105 { diff --git a/internal/providers/python/source_distribution.go b/internal/providers/python/source_distribution.go index 9612a751..021d0d7b 100644 --- a/internal/providers/python/source_distribution.go +++ b/internal/providers/python/source_distribution.go @@ -284,9 +284,14 @@ func inspectSourceDistributionArchive(filename string) (SourceDistributionMetada ) } } - if root == "" || paths[root] != tar.TypeDir { + if root == "" { return SourceDistributionMetadataV1{}, fmt.Errorf( - "Python source distribution must contain one explicit top-level directory", + "Python source distribution must contain exactly one top-level directory", + ) + } + if rootType, found := paths[root]; found && rootType != tar.TypeDir { + return SourceDistributionMetadataV1{}, fmt.Errorf( + "Python source distribution top-level path %q must be a directory", root, ) } for name := range paths { @@ -297,6 +302,12 @@ func inspectSourceDistributionArchive(filename string) (SourceDistributionMetada name, parent, ) } + if parentType, found := paths[parent]; found && parentType != tar.TypeDir { + return SourceDistributionMetadataV1{}, fmt.Errorf( + "Python source distribution path %q traverses non-directory ancestor %q", + name, parent, + ) + } } } if len(packageMetadata) == 0 { diff --git a/internal/providers/python/source_distribution_test.go b/internal/providers/python/source_distribution_test.go index 606acf92..fac41905 100644 --- a/internal/providers/python/source_distribution_test.go +++ b/internal/providers/python/source_distribution_test.go @@ -61,6 +61,46 @@ func TestSourceDistributionValidationAndExtraction(t *testing.T) { } } +func TestSourceDistributionAcceptsImplicitSingleRoot(t *testing.T) { + archive := filepath.Join(t.TempDir(), "demo_pkg-1.2.3.tar.gz") + writeTestSourceDistribution(t, archive, []testSourceDistributionEntry{ + {name: "demo_pkg-1.2.3/pyproject.toml", kind: tar.TypeReg, content: "[build-system]\n"}, + {name: "demo_pkg-1.2.3/PKG-INFO", kind: tar.TypeReg, content: "Name: Demo-Pkg\nVersion: 1.2.3\n\n"}, + {name: "demo_pkg-1.2.3/src/demo.py", kind: tar.TypeReg, content: "value = 1\n"}, + }) + descriptor, metadata, err := DescribeSourceDistributionFileV1( + archive, "sdists/demo_pkg-1.2.3.tar.gz", + ) + if err != nil { + t.Fatal(err) + } + if descriptor.Kind != "sdist" || metadata.Root != "demo_pkg-1.2.3" { + t.Fatalf("descriptor/metadata = %#v / %#v", descriptor, metadata) + } + destination := t.TempDir() + if _, err := ExtractSourceDistributionFileV1(archive, destination); err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(filepath.Join(destination, "demo_pkg-1.2.3", "src", "demo.py")) + if err != nil || string(content) != "value = 1\n" { + t.Fatalf("implicit-root extracted file = %q, %v", content, err) + } +} + +func TestSourceDistributionRejectsExplicitNonDirectoryRoot(t *testing.T) { + archive := filepath.Join(t.TempDir(), "demo-1.tar.gz") + writeTestSourceDistribution(t, archive, []testSourceDistributionEntry{ + {name: "demo-1", kind: tar.TypeReg, content: "not a directory"}, + {name: "demo-1/pyproject.toml", kind: tar.TypeReg, content: "[build-system]\n"}, + {name: "demo-1/PKG-INFO", kind: tar.TypeReg, content: "Name: demo\nVersion: 1\n\n"}, + }) + if _, _, err := DescribeSourceDistributionFileV1( + archive, "sdists/demo-1.tar.gz", + ); err == nil || !strings.Contains(err.Error(), "top-level path \"demo-1\" must be a directory") { + t.Fatalf("error = %v", err) + } +} + func TestSourceDistributionRejectsUnsafeArchiveShapes(t *testing.T) { tests := []struct { name string @@ -107,6 +147,21 @@ func TestSourceDistributionRejectsUnsafeArchiveShapes(t *testing.T) { } } +func TestSourceDistributionRejectsExplicitNonDirectoryAncestor(t *testing.T) { + archive := filepath.Join(t.TempDir(), "demo-1.tar.gz") + writeTestSourceDistribution(t, archive, []testSourceDistributionEntry{ + {name: "demo-1/pyproject.toml", kind: tar.TypeReg, content: "[build-system]\n"}, + {name: "demo-1/PKG-INFO", kind: tar.TypeReg, content: "Name: demo\nVersion: 1\n\n"}, + {name: "demo-1/src", kind: tar.TypeReg, content: "not a directory"}, + {name: "demo-1/src/demo.py", kind: tar.TypeReg, content: "value = 1\n"}, + }) + if _, _, err := DescribeSourceDistributionFileV1( + archive, "sdists/demo-1.tar.gz", + ); err == nil || !strings.Contains(err.Error(), "non-directory ancestor") { + t.Fatalf("error = %v", err) + } +} + func TestSourceDistributionPKGINFOErrorsNameTheSdistMetadata(t *testing.T) { archive := filepath.Join(t.TempDir(), "demo-1.tar.gz") writeTestSourceDistribution(t, archive, []testSourceDistributionEntry{