diff --git a/internal/app/azldev/cmds/component/render.go b/internal/app/azldev/cmds/component/render.go index 20126af2..3a9a8bf2 100644 --- a/internal/app/azldev/cmds/component/render.go +++ b/internal/app/azldev/cmds/component/render.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "log/slog" + "os" "path/filepath" "slices" "strings" @@ -19,6 +20,7 @@ import ( "github.com/microsoft/azure-linux-dev-tools/internal/providers/sourceproviders" "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms" "github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils" + "github.com/spf13/afero" "github.com/spf13/cobra" ) @@ -114,6 +116,17 @@ const ( renderStatusCancelled = "cancelled" ) +// caseCollisionError wraps an error produced by [checkCaseCollisions]. It signals +// that the rendered files were successfully copied to the output directory, but +// one or more case-insensitive filename collisions were detected. The caller should +// mark the component as an error but must NOT delete the copied output. +type caseCollisionError struct { + cause error +} + +func (e *caseCollisionError) Error() string { return e.cause.Error() } +func (e *caseCollisionError) Unwrap() error { return e.cause } + // RenderComponents renders the post-overlay spec and sidecar files for each // selected component into the output directory. Processing is done in three phases: // 1. Parallel source preparation (clone, overlay, synthetic git) @@ -594,16 +607,22 @@ func finishOneComponent( result.Status = renderStatusError result.Error = err.Error() - // Clean any stale good output before writing the failure marker. - // Only allowed for managed (project-local) output directories. - if allowOverwrite { - if removeErr := env.FS().RemoveAll(compOutputDir); removeErr != nil { - slog.Debug("Failed to clean output before writing error marker", - "path", compOutputDir, "error", removeErr) + // For case-collision errors the files were successfully copied to the output + // directory — do not delete them or write a failure marker. The collision + // message in the table is sufficient to prompt the user to add an overlay. + var collisionErr *caseCollisionError + if !errors.As(err, &collisionErr) { + // Clean any stale good output before writing the failure marker. + // Only allowed for managed (project-local) output directories. + if allowOverwrite { + if removeErr := env.FS().RemoveAll(compOutputDir); removeErr != nil { + slog.Debug("Failed to clean output before writing error marker", + "path", compOutputDir, "error", removeErr) + } } - } - writeRenderErrorMarker(env.FS(), compOutputDir) + writeRenderErrorMarker(env.FS(), compOutputDir) + } } return result @@ -649,6 +668,17 @@ func finishComponentRender( slog.Debug("Failed to remove .git directory", "path", gitDir, "error", removeErr) } + // Check for files whose names differ only by case. Such files would collide + // on case-insensitive filesystems (e.g. Windows git clones). We detect this + // before copying so the error is reported, but we still copy the files so the + // rendered output is as complete as possible (the tool runs on Linux where + // the colliding files coexist without issue). + collisionErr := checkCaseCollisions(env.FS(), componentDir) + if collisionErr != nil { + slog.Warn("Case-insensitive filename collision detected", + "component", componentName, "error", collisionErr) + } + // Copy rendered files to the component's output directory. if copyErr := copyRenderedOutput(env, componentDir, prep.compOutputDir, allowOverwrite); copyErr != nil { return copyErr @@ -657,6 +687,12 @@ func finishComponentRender( slog.Info("Rendered component", "component", componentName, "output", prep.compOutputDir) + if collisionErr != nil { + return &caseCollisionError{ + cause: fmt.Errorf("case-insensitive filename collision in %#q:\n%w", componentName, collisionErr), + } + } + return nil } @@ -742,6 +778,56 @@ func removeUnreferencedFiles(fs opctx.FS, tempDir, specPath string, specFiles [] return nil } +// checkCaseCollisions walks dir recursively and returns an error if any two files +// have names that differ only in case, which would collide on case-insensitive +// filesystems (e.g. Windows git clones). The error message lists the colliding +// pairs and suggests creating an overlay to rename one of them. +func checkCaseCollisions(fs opctx.FS, dir string) error { + // Map lowercase relative path → first-seen relative path with original casing. + seen := make(map[string]string) + + var collisions []string + + walkErr := afero.Walk(fs, dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return fmt.Errorf("error walking %#q:\n%w", path, err) + } + + if info.IsDir() { + return nil + } + + rel, relErr := filepath.Rel(dir, path) + if relErr != nil { + return fmt.Errorf("computing relative path for %#q:\n%w", path, relErr) + } + + lower := strings.ToLower(rel) + if existing, ok := seen[lower]; ok { + collisions = append(collisions, fmt.Sprintf("%#q collides with %#q", rel, existing)) + } else { + seen[lower] = rel + } + + return nil + }) + + if walkErr != nil { + return fmt.Errorf("walking directory %#q:\n%w", dir, walkErr) + } + + if len(collisions) > 0 { + slices.Sort(collisions) + + return fmt.Errorf( + "files with names that differ only in case would collide on case-insensitive filesystems (e.g. Windows git clones):\n%s\n"+ + "to fix, create an overlay to rename one of the colliding files", + strings.Join(collisions, "\n")) + } + + return nil +} + // findSpecFile locates the spec file for a component in the given directory. func findSpecFile(fs opctx.FS, dir, componentName string) (string, error) { specPath := filepath.Join(dir, componentName+".spec") diff --git a/internal/app/azldev/cmds/component/render_internal_test.go b/internal/app/azldev/cmds/component/render_internal_test.go index 85f7d420..cefdb9b3 100644 --- a/internal/app/azldev/cmds/component/render_internal_test.go +++ b/internal/app/azldev/cmds/component/render_internal_test.go @@ -264,3 +264,69 @@ func TestRemoveUnreferencedFiles(t *testing.T) { assert.Len(t, entries, 2, "both files should remain") }) } + +func TestCheckCaseCollisions(t *testing.T) { + t.Run("no error when all filenames are unique case-insensitively", func(t *testing.T) { + testFS := afero.NewMemMapFs() + + require.NoError(t, fileutils.MkdirAll(testFS, "/render")) + require.NoError(t, fileutils.WriteFile(testFS, "/render/curl.spec", []byte("spec"), fileperms.PublicFile)) + require.NoError(t, fileutils.WriteFile(testFS, "/render/fix-build.patch", []byte("patch"), fileperms.PublicFile)) + require.NoError(t, fileutils.WriteFile(testFS, "/render/curl-8.0.tar.xz", []byte("src"), fileperms.PublicFile)) + + err := checkCaseCollisions(testFS, "/render") + require.NoError(t, err) + }) + + t.Run("error when two files differ only in case", func(t *testing.T) { + testFS := afero.NewMemMapFs() + + require.NoError(t, fileutils.MkdirAll(testFS, "/render")) + require.NoError(t, fileutils.WriteFile(testFS, "/render/curl.spec", []byte("spec"), fileperms.PublicFile)) + require.NoError(t, fileutils.WriteFile(testFS, "/render/0001-Implement-support-for-PPC64-on-Linux.patch", []byte("patch1"), fileperms.PublicFile)) + require.NoError(t, fileutils.WriteFile(testFS, "/render/0001-Implement-support-for-ppc64-on-Linux.patch", []byte("patch2"), fileperms.PublicFile)) + + err := checkCaseCollisions(testFS, "/render") + require.Error(t, err) + assert.Contains(t, err.Error(), "collides with") + assert.Contains(t, err.Error(), "overlay") + }) + + t.Run("error when files in subdirectory differ only in case", func(t *testing.T) { + testFS := afero.NewMemMapFs() + + require.NoError(t, fileutils.MkdirAll(testFS, "/render/patches")) + require.NoError(t, fileutils.WriteFile(testFS, "/render/curl.spec", []byte("spec"), fileperms.PublicFile)) + require.NoError(t, fileutils.WriteFile(testFS, "/render/patches/Fix-Build.patch", []byte("patch1"), fileperms.PublicFile)) + require.NoError(t, fileutils.WriteFile(testFS, "/render/patches/fix-build.patch", []byte("patch2"), fileperms.PublicFile)) + + err := checkCaseCollisions(testFS, "/render") + require.Error(t, err) + assert.Contains(t, err.Error(), "collides with") + assert.Contains(t, err.Error(), "overlay") + }) + + t.Run("no error for empty directory", func(t *testing.T) { + testFS := afero.NewMemMapFs() + + require.NoError(t, fileutils.MkdirAll(testFS, "/render")) + + err := checkCaseCollisions(testFS, "/render") + require.NoError(t, err) + }) + + t.Run("error lists all colliding pairs", func(t *testing.T) { + testFS := afero.NewMemMapFs() + + require.NoError(t, fileutils.MkdirAll(testFS, "/render")) + require.NoError(t, fileutils.WriteFile(testFS, "/render/Patch-A.patch", []byte("a"), fileperms.PublicFile)) + require.NoError(t, fileutils.WriteFile(testFS, "/render/patch-a.patch", []byte("b"), fileperms.PublicFile)) + require.NoError(t, fileutils.WriteFile(testFS, "/render/Patch-B.patch", []byte("c"), fileperms.PublicFile)) + require.NoError(t, fileutils.WriteFile(testFS, "/render/patch-b.patch", []byte("d"), fileperms.PublicFile)) + + err := checkCaseCollisions(testFS, "/render") + require.Error(t, err) + assert.Contains(t, err.Error(), "Patch-A.patch") + assert.Contains(t, err.Error(), "Patch-B.patch") + }) +}