Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 94 additions & 8 deletions internal/app/azldev/cmds/component/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"slices"
"strings"
Expand All @@ -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"
)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
}

Expand Down Expand Up @@ -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")
Expand Down
66 changes: 66 additions & 0 deletions internal/app/azldev/cmds/component/render_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
}
Loading