From fcdd624e8320cd2eeea730a758a1193c0e01b005 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Mon, 13 Apr 2026 07:06:26 -0700 Subject: [PATCH 1/3] feat: add .azdignore support for template init Add support for .azdignore files that allow template authors to exclude files when consumers run 'azd init' from a template. This addresses the long-standing request from template authors who include documentation, CI configs, and other files that end consumers don't need. Key design decisions: - Root-only: .azdignore is read from the template root only - Self-excluding: .azdignore file itself is always excluded from output - Uses go-gitignore library (same as rest of codebase) - UTF-8 BOM stripping for cross-platform compatibility - Works with both remote and local template paths Resolves #4142 Relates to #7669 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/.vscode/cspell.yaml | 1 + cli/azd/internal/repository/initializer.go | 138 ++++ .../internal/repository/initializer_test.go | 773 ++++++++++++++++++ 3 files changed, 912 insertions(+) diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index d9e20981c39..386ee69820e 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -3,6 +3,7 @@ words: - agentcopilot - agentdetect - Authenticode + - azdignore - gofmt - golangci - lightspeed diff --git a/cli/azd/internal/repository/initializer.go b/cli/azd/internal/repository/initializer.go index 6bfc2d520ca..08e5f3a7fca 100644 --- a/cli/azd/internal/repository/initializer.go +++ b/cli/azd/internal/repository/initializer.go @@ -6,9 +6,11 @@ package repository import ( "bufio" + "bytes" "context" "errors" "fmt" + "io" "io/fs" "log" "maps" @@ -35,6 +37,10 @@ import ( "github.com/otiai10/copy" ) +// azdIgnoreFileName is the name of the file template authors can place at the root of a template +// repository to exclude files from being copied when consumers run azd init. It uses .gitignore syntax. +const azdIgnoreFileName = ".azdignore" + // Initializer handles the initialization of a local repository. type Initializer struct { console input.Console @@ -127,6 +133,15 @@ func (i *Initializer) Initialize( return err } + // Apply .azdignore rules: remove files from staging that template authors + // want excluded when consumers init from the template. + if err := removeAzdIgnoredFiles(staging); err != nil { + return fmt.Errorf("applying %s rules: %w", azdIgnoreFileName, err) + } + // Executable file paths may reference files removed by .azdignore; + // keep only those still present in staging. + filesWithExecPerms = filterExistingFiles(staging, filesWithExecPerms) + skipStagingFiles, err := i.promptForDuplicates(ctx, staging, target) if err != nil { return err @@ -262,6 +277,13 @@ func (i *Initializer) copyLocalTemplate(source, destination string) error { return true, nil } + // Never skip the root .azdignore — it must reach staging so + // removeAzdIgnoredFiles can apply its rules and then remove it. + if relToSource, relErr := filepath.Rel(source, src); relErr == nil && + filepath.ToSlash(relToSource) == azdIgnoreFileName { + return false, nil + } + // Check all applicable .gitignore matchers (a matcher applies when // the file is inside the matcher's base directory). for _, m := range matchers { @@ -286,6 +308,122 @@ func (i *Initializer) copyLocalTemplate(source, destination string) error { return nil } +// azdIgnoreMaxSize is the maximum allowed size for a .azdignore file (1 MB). +const azdIgnoreMaxSize = 1 << 20 + +// loadAzdIgnore reads an .azdignore file from the root of dir and returns a +// gitignore-syntax matcher. Returns nil if no .azdignore file exists. +// Symlinks are rejected to prevent reading files outside the staging directory. +func loadAzdIgnore(dir string) (gitignore.GitIgnore, error) { + path := filepath.Join(dir, azdIgnoreFileName) + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("reading %s: %w", azdIgnoreFileName, err) + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf( + "%s must be a regular file", azdIgnoreFileName) + } + if info.Size() > azdIgnoreMaxSize { + return nil, fmt.Errorf( + "%s exceeds maximum size (%d bytes)", azdIgnoreFileName, azdIgnoreMaxSize) + } + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", azdIgnoreFileName, err) + } + defer f.Close() + // Use LimitReader to enforce the size limit on actual bytes read, + // guarding against TOCTOU between Lstat and Open. + data, err := io.ReadAll(io.LimitReader(f, azdIgnoreMaxSize+1)) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", azdIgnoreFileName, err) + } + if int64(len(data)) > azdIgnoreMaxSize { + return nil, fmt.Errorf( + "%s exceeds maximum size (%d bytes)", azdIgnoreFileName, azdIgnoreMaxSize) + } + // Strip UTF-8 BOM that Windows editors may prepend. The invisible bytes + // would otherwise become part of the first ignore pattern and break matching. + data = bytes.TrimPrefix(data, utf8BOM) + return gitignore.New(bytes.NewReader(data), dir, nil), nil +} + +// utf8BOM is the byte order mark that some Windows editors prepend to UTF-8 files. +var utf8BOM = []byte{0xEF, 0xBB, 0xBF} + +// removeAzdIgnoredFiles reads an .azdignore file from dir and removes all matching +// entries. The .azdignore file itself is also removed. This is a no-op when no +// .azdignore file is present. +func removeAzdIgnoredFiles(dir string) error { + ig, err := loadAzdIgnore(dir) + if err != nil { + return err + } + if ig == nil { + return nil + } + + // Collect paths to remove before modifying the tree. + var toRemove []string + err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + rel, relErr := filepath.Rel(dir, path) + if relErr != nil { + return relErr + } + if rel == "." { + return nil + } + + match := ig.Relative(filepath.ToSlash(rel), d.IsDir()) + if match != nil && match.Ignore() { + toRemove = append(toRemove, path) + if d.IsDir() { + return filepath.SkipDir + } + } + return nil + }) + if err != nil { + return fmt.Errorf("scanning for %s matches: %w", azdIgnoreFileName, err) + } + + for _, p := range toRemove { + if err := os.RemoveAll(p); err != nil { + return fmt.Errorf("removing %s-ignored path: %w", azdIgnoreFileName, err) + } + } + + // Always remove .azdignore itself — consumers don't need it. + azdIgnorePath := filepath.Join(dir, azdIgnoreFileName) + if err := os.Remove(azdIgnorePath); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("removing %s file: %w", azdIgnoreFileName, err) + } + + return nil +} + +// filterExistingFiles returns only those relative paths that still exist under dir. +func filterExistingFiles(dir string, paths []string) []string { + if len(paths) == 0 { + return paths + } + filtered := make([]string, 0, len(paths)) + for _, p := range paths { + if _, err := os.Stat(filepath.Join(dir, p)); err == nil { + filtered = append(filtered, p) + } + } + return filtered +} + // findExecutableFiles walks root and returns paths (relative to root) of files // that have any executable permission bit set. On Windows, exec bits are not // meaningful so an empty list is returned immediately. diff --git a/cli/azd/internal/repository/initializer_test.go b/cli/azd/internal/repository/initializer_test.go index 56b48a7a7ba..f92488712b3 100644 --- a/cli/azd/internal/repository/initializer_test.go +++ b/cli/azd/internal/repository/initializer_test.go @@ -32,6 +32,7 @@ import ( "github.com/azure/azure-dev/cli/azd/test/mocks/mockenv" "github.com/azure/azure-dev/cli/azd/test/mocks/mockexec" "github.com/azure/azure-dev/cli/azd/test/mocks/mockinput" + cp "github.com/otiai10/copy" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -1182,3 +1183,775 @@ func Test_Initializer_Initialize_LocalTemplateOverlapRejected(t *testing.T) { assert.Contains(t, err.Error(), "overlaps with template source") }) } + +// --- .azdignore tests --- + +func Test_removeAzdIgnoredFiles(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + azdIgnore string // contents of .azdignore; empty means no file + files []string + dirs []string + expectFiles []string // files expected to remain + expectAbsent []string // paths expected to be removed + }{ + { + name: "NoAzdIgnore", + azdIgnore: "", + files: []string{"README.md", "src/main.go"}, + expectFiles: []string{"README.md", "src/main.go"}, + }, + { + name: "GlobPatterns", + azdIgnore: "*.yml\nCONTRIBUTING.md\n", + files: []string{"README.md", "ci.yml", "deploy.yml", "CONTRIBUTING.md", "src/main.go"}, + expectFiles: []string{ + "README.md", "src/main.go", + }, + expectAbsent: []string{"ci.yml", "deploy.yml", "CONTRIBUTING.md"}, + }, + { + name: "DirectoryExclusion", + azdIgnore: ".github/\n", + files: []string{"README.md", ".github/workflows/ci.yml", ".github/CODEOWNERS"}, + dirs: []string{".github/workflows"}, + expectFiles: []string{ + "README.md", + }, + expectAbsent: []string{".github"}, + }, + { + name: "NegationPattern", + azdIgnore: "*.md\n!README.md\n", + files: []string{"README.md", "CONTRIBUTING.md", "CODE_OF_CONDUCT.md", "src/main.go"}, + expectFiles: []string{ + "README.md", "src/main.go", + }, + expectAbsent: []string{"CONTRIBUTING.md", "CODE_OF_CONDUCT.md"}, + }, + { + name: "NestedFilePattern", + azdIgnore: "docs/internal/\n", + files: []string{"docs/public/guide.md", "docs/internal/design.md", "docs/internal/notes.md"}, + dirs: []string{"docs/public", "docs/internal"}, + expectFiles: []string{ + "docs/public/guide.md", + }, + expectAbsent: []string{"docs/internal"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + // Create directories first + for _, d := range tt.dirs { + require.NoError(t, os.MkdirAll(filepath.Join(dir, d), 0755)) + } + + // Create files + for _, f := range tt.files { + require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(dir, f)), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, f), []byte("content"), 0600)) + } + + // Create .azdignore if specified + if tt.azdIgnore != "" { + require.NoError(t, os.WriteFile( + filepath.Join(dir, azdIgnoreFileName), []byte(tt.azdIgnore), 0600)) + } + + err := removeAzdIgnoredFiles(dir) + require.NoError(t, err) + + // Verify expected files remain + for _, f := range tt.expectFiles { + require.FileExists(t, filepath.Join(dir, f), "expected file to remain: %s", f) + } + + // Verify removed files are gone + for _, f := range tt.expectAbsent { + p := filepath.Join(dir, f) + require.NoFileExists(t, p, "expected path to be removed: %s", f) + require.NoDirExists(t, p, "expected path to be removed: %s", f) + } + + // .azdignore itself should always be removed when it existed + if tt.azdIgnore != "" { + require.NoFileExists(t, filepath.Join(dir, azdIgnoreFileName)) + } + }) + } +} + +func Test_loadAzdIgnore(t *testing.T) { + t.Parallel() + + t.Run("FileExists", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, azdIgnoreFileName), []byte("*.log\n"), 0600)) + + ig, err := loadAzdIgnore(dir) + require.NoError(t, err) + require.NotNil(t, ig) + + // Verify it matches + match := ig.Relative("debug.log", false) + require.NotNil(t, match) + require.True(t, match.Ignore()) + + // Verify non-match + match = ig.Relative("main.go", false) + if match != nil { + require.False(t, match.Ignore()) + } + }) + + t.Run("FileDoesNotExist", func(t *testing.T) { + dir := t.TempDir() + + ig, err := loadAzdIgnore(dir) + require.NoError(t, err) + require.Nil(t, ig) + }) + + t.Run("FileWithUTF8BOM", func(t *testing.T) { + dir := t.TempDir() + // Prepend UTF-8 BOM (0xEF, 0xBB, 0xBF) — Windows editors may add this. + // Without stripping, the BOM becomes part of the first pattern and breaks matching. + content := append([]byte{0xEF, 0xBB, 0xBF}, []byte("*.log\n")...) + require.NoError(t, os.WriteFile( + filepath.Join(dir, azdIgnoreFileName), content, 0600)) + + ig, err := loadAzdIgnore(dir) + require.NoError(t, err) + require.NotNil(t, ig) + + // Verify the first pattern still matches despite BOM + match := ig.Relative("debug.log", false) + require.NotNil(t, match) + require.True(t, match.Ignore()) + }) +} + +func Test_filterExistingFiles(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + require.NoError(t, os.WriteFile(filepath.Join(dir, "exists.txt"), []byte("hi"), 0600)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "sub"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "sub", "also.txt"), []byte("hi"), 0600)) + + result := filterExistingFiles(dir, []string{"exists.txt", "gone.txt", "sub/also.txt", "sub/nope.txt"}) + assert.Equal(t, []string{"exists.txt", "sub/also.txt"}, result) + + // Empty input returns empty + result = filterExistingFiles(dir, nil) + assert.Nil(t, result) +} + +func Test_Initializer_Initialize_AzdIgnoreRemoteTemplate(t *testing.T) { + t.Parallel() + + // Build a source template directory that simulates a cloned repo + sourceTemplate := t.TempDir() + + // Create normal template files + require.NoError(t, os.WriteFile( + filepath.Join(sourceTemplate, "azure.yaml"), []byte("name: test\n"), 0600)) + require.NoError(t, os.WriteFile( + filepath.Join(sourceTemplate, "README.md"), []byte("# Test\n"), 0600)) + require.NoError(t, os.MkdirAll(filepath.Join(sourceTemplate, "src"), 0755)) + require.NoError(t, os.WriteFile( + filepath.Join(sourceTemplate, "src", "main.go"), []byte("package main\n"), 0600)) + + // Create files that should be excluded by .azdignore + require.NoError(t, os.MkdirAll(filepath.Join(sourceTemplate, ".github", "workflows"), 0755)) + require.NoError(t, os.WriteFile( + filepath.Join(sourceTemplate, ".github", "workflows", "ci.yml"), + []byte("name: CI\n"), + 0600)) + require.NoError(t, os.WriteFile( + filepath.Join(sourceTemplate, "CONTRIBUTING.md"), + []byte("# Contributing\n"), + 0600)) + require.NoError(t, os.WriteFile( + filepath.Join(sourceTemplate, "CODE_OF_CONDUCT.md"), + []byte("# Code of Conduct\n"), + 0600)) + + // Create .azdignore + azdIgnoreContent := ".github/\nCONTRIBUTING.md\nCODE_OF_CONDUCT.md\n" + require.NoError(t, os.WriteFile( + filepath.Join(sourceTemplate, azdIgnoreFileName), + []byte(azdIgnoreContent), + 0600)) + + projectDir := t.TempDir() + azdCtx := azdcontext.NewAzdContextWithDirectory(projectDir) + + realRunner := exec.NewCommandRunner(nil) + mockRunner := mockexec.NewMockCommandRunner() + const repoURL = "https://github.com/Azure-Samples/azdignore-test" + mockRunner.When(func(args exec.RunArgs, command string) bool { return true }). + RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + if slices.Contains(args.Args, "clone") && slices.Contains(args.Args, repoURL) { + stagingDir := args.Args[len(args.Args)-1] + + // Copy source template to staging (simulating git clone) + err := cp.Copy(sourceTemplate, stagingDir) + require.NoError(t, err) + + // Create a git repo so fetchCode's post-clone steps succeed + _, err = realRunner.Run(t.Context(), + exec.NewRunArgs("git", "-C", stagingDir, "init")) + require.NoError(t, err) + _, err = realRunner.Run(t.Context(), + exec.NewRunArgs("git", "-C", stagingDir, "add", "-A")) + require.NoError(t, err) + + return exec.NewRunResult(0, "", ""), nil + } + return realRunner.Run(t.Context(), args) + }) + + mockEnv := &mockenv.MockEnvManager{} + mockEnv.On("Save", mock.Anything, mock.Anything).Return(nil) + + i := NewInitializer( + mockinput.NewMockConsole(), + git.NewCli(mockRunner), + dotnet.NewCli(mockRunner), + alpha.NewFeaturesManagerWithConfig(config.NewEmptyConfig()), + lazy.From[environment.Manager](mockEnv), + ) + + err := i.Initialize(t.Context(), azdCtx, &templates.Template{ + RepositoryPath: repoURL, + }, "") + require.NoError(t, err) + + // Verify included files were copied + require.FileExists(t, filepath.Join(projectDir, "README.md")) + require.FileExists(t, filepath.Join(projectDir, "src", "main.go")) + + // Verify .azdignore itself was excluded + require.NoFileExists(t, filepath.Join(projectDir, azdIgnoreFileName)) + + // Verify .azdignore'd files were excluded + require.NoDirExists(t, filepath.Join(projectDir, ".github")) + require.NoFileExists(t, filepath.Join(projectDir, "CONTRIBUTING.md")) + require.NoFileExists(t, filepath.Join(projectDir, "CODE_OF_CONDUCT.md")) + + // Verify standard azd assets + require.FileExists(t, filepath.Join(projectDir, ".gitignore")) + require.DirExists(t, azdCtx.EnvironmentDirectory()) +} + +func Test_Initializer_Initialize_AzdIgnoreLocalTemplate(t *testing.T) { + t.Parallel() + + // Create a local template directory + localTemplateDir := createLocalTemplateDir(t, testDataPath("template")) + + // Create files that should be excluded by .azdignore + require.NoError(t, os.MkdirAll(filepath.Join(localTemplateDir, ".github", "workflows"), 0755)) + require.NoError(t, os.WriteFile( + filepath.Join(localTemplateDir, ".github", "workflows", "ci.yml"), + []byte("name: CI\n"), + 0600)) + require.NoError(t, os.WriteFile( + filepath.Join(localTemplateDir, "CONTRIBUTING.md"), + []byte("# Contributing\n"), + 0600)) + + // Create .azdignore + azdIgnoreContent := ".github/\nCONTRIBUTING.md\n" + require.NoError(t, os.WriteFile( + filepath.Join(localTemplateDir, azdIgnoreFileName), + []byte(azdIgnoreContent), + 0600)) + + projectDir := t.TempDir() + azdCtx := azdcontext.NewAzdContextWithDirectory(projectDir) + + realRunner := exec.NewCommandRunner(nil) + + mockEnv := &mockenv.MockEnvManager{} + mockEnv.On("Save", mock.Anything, mock.Anything).Return(nil) + + i := NewInitializer( + mockinput.NewMockConsole(), + git.NewCli(realRunner), + dotnet.NewCli(realRunner), + alpha.NewFeaturesManagerWithConfig(config.NewEmptyConfig()), + lazy.From[environment.Manager](mockEnv), + ) + + err := i.Initialize(t.Context(), azdCtx, &templates.Template{ + RepositoryPath: localTemplateDir, + }, "") + require.NoError(t, err) + + // Verify included files were copied + require.FileExists(t, filepath.Join(projectDir, "README.md")) + require.FileExists(t, filepath.Join(projectDir, "src", "Program.cs")) + + // Verify .azdignore itself was excluded + require.NoFileExists(t, filepath.Join(projectDir, azdIgnoreFileName)) + + // Verify .azdignore'd files were excluded + require.NoDirExists(t, filepath.Join(projectDir, ".github")) + require.NoFileExists(t, filepath.Join(projectDir, "CONTRIBUTING.md")) + + // Verify standard azd assets + require.FileExists(t, filepath.Join(projectDir, ".gitignore")) + require.DirExists(t, azdCtx.EnvironmentDirectory()) +} + +func Test_Initializer_Initialize_AzdIgnoreNegation(t *testing.T) { + t.Parallel() + + localTemplateDir := createLocalTemplateDir(t, testDataPath("template")) + + // Create .azdignore with negation pattern + azdIgnoreContent := "*.md\n!README.md\n" + require.NoError(t, os.WriteFile( + filepath.Join(localTemplateDir, azdIgnoreFileName), + []byte(azdIgnoreContent), + 0600)) + + // Create extra .md files + require.NoError(t, os.WriteFile( + filepath.Join(localTemplateDir, "CONTRIBUTING.md"), + []byte("# Contributing\n"), + 0600)) + require.NoError(t, os.WriteFile( + filepath.Join(localTemplateDir, "CODE_OF_CONDUCT.md"), + []byte("# Code of Conduct\n"), + 0600)) + + projectDir := t.TempDir() + azdCtx := azdcontext.NewAzdContextWithDirectory(projectDir) + + realRunner := exec.NewCommandRunner(nil) + + mockEnv := &mockenv.MockEnvManager{} + mockEnv.On("Save", mock.Anything, mock.Anything).Return(nil) + + i := NewInitializer( + mockinput.NewMockConsole(), + git.NewCli(realRunner), + dotnet.NewCli(realRunner), + alpha.NewFeaturesManagerWithConfig(config.NewEmptyConfig()), + lazy.From[environment.Manager](mockEnv), + ) + + err := i.Initialize(t.Context(), azdCtx, &templates.Template{ + RepositoryPath: localTemplateDir, + }, "") + require.NoError(t, err) + + // README.md should be kept via negation + require.FileExists(t, filepath.Join(projectDir, "README.md")) + + // Other .md files should be excluded + require.NoFileExists(t, filepath.Join(projectDir, "CONTRIBUTING.md")) + require.NoFileExists(t, filepath.Join(projectDir, "CODE_OF_CONDUCT.md")) + + // .azdignore itself should be excluded + require.NoFileExists(t, filepath.Join(projectDir, azdIgnoreFileName)) +} + +func Test_Initializer_Initialize_AzdIgnoreWithGitignore(t *testing.T) { + t.Parallel() + + // Verify that .azdignore and .gitignore work together in local templates + localTemplateDir := createLocalTemplateDir(t, testDataPath("template")) + + // Create .gitignore that excludes build artifacts + require.NoError(t, os.WriteFile( + filepath.Join(localTemplateDir, ".gitignore"), + []byte("build/\n*.log\n"), + 0600)) + + // Create .azdignore that excludes repo-authoring files + require.NoError(t, os.WriteFile( + filepath.Join(localTemplateDir, azdIgnoreFileName), + []byte("CONTRIBUTING.md\n"), + 0600)) + + // Create files: some excluded by .gitignore, some by .azdignore, some kept + require.NoError(t, os.MkdirAll(filepath.Join(localTemplateDir, "build"), 0755)) + require.NoError(t, os.WriteFile( + filepath.Join(localTemplateDir, "build", "out.js"), []byte("compiled"), 0600)) + require.NoError(t, os.WriteFile( + filepath.Join(localTemplateDir, "debug.log"), []byte("log"), 0600)) + require.NoError(t, os.WriteFile( + filepath.Join(localTemplateDir, "CONTRIBUTING.md"), []byte("contrib"), 0600)) + require.NoError(t, os.WriteFile( + filepath.Join(localTemplateDir, "keep.txt"), []byte("keep"), 0600)) + + projectDir := t.TempDir() + azdCtx := azdcontext.NewAzdContextWithDirectory(projectDir) + + realRunner := exec.NewCommandRunner(nil) + + mockEnv := &mockenv.MockEnvManager{} + mockEnv.On("Save", mock.Anything, mock.Anything).Return(nil) + + i := NewInitializer( + mockinput.NewMockConsole(), + git.NewCli(realRunner), + dotnet.NewCli(realRunner), + alpha.NewFeaturesManagerWithConfig(config.NewEmptyConfig()), + lazy.From[environment.Manager](mockEnv), + ) + + err := i.Initialize(t.Context(), azdCtx, &templates.Template{ + RepositoryPath: localTemplateDir, + }, "") + require.NoError(t, err) + + // Kept files + require.FileExists(t, filepath.Join(projectDir, "keep.txt")) + require.FileExists(t, filepath.Join(projectDir, "README.md")) + + // Excluded by .gitignore + require.NoDirExists(t, filepath.Join(projectDir, "build")) + require.NoFileExists(t, filepath.Join(projectDir, "debug.log")) + + // Excluded by .azdignore + require.NoFileExists(t, filepath.Join(projectDir, "CONTRIBUTING.md")) + require.NoFileExists(t, filepath.Join(projectDir, azdIgnoreFileName)) +} + +func Test_Initializer_Initialize_AzdIgnoreSurvivesGitignore(t *testing.T) { + t.Parallel() + + // Regression test: if the template's .gitignore contains a broad pattern + // that matches .azdignore (e.g., ".*"), the .azdignore file must still be + // copied to staging so that its rules are applied before it is removed. + localTemplateDir := createLocalTemplateDir(t, testDataPath("template")) + + // .gitignore with a broad dot-file pattern that would match .azdignore + require.NoError(t, os.WriteFile( + filepath.Join(localTemplateDir, ".gitignore"), + []byte(".*\n"), + 0600)) + + // .azdignore should still take effect despite the .gitignore pattern + require.NoError(t, os.WriteFile( + filepath.Join(localTemplateDir, azdIgnoreFileName), + []byte("CONTRIBUTING.md\n"), + 0600)) + require.NoError(t, os.WriteFile( + filepath.Join(localTemplateDir, "CONTRIBUTING.md"), + []byte("# Contributing\n"), + 0600)) + require.NoError(t, os.WriteFile( + filepath.Join(localTemplateDir, "keep.txt"), + []byte("keep"), + 0600)) + + projectDir := t.TempDir() + azdCtx := azdcontext.NewAzdContextWithDirectory(projectDir) + + realRunner := exec.NewCommandRunner(nil) + + mockEnv := &mockenv.MockEnvManager{} + mockEnv.On("Save", mock.Anything, mock.Anything).Return(nil) + + i := NewInitializer( + mockinput.NewMockConsole(), + git.NewCli(realRunner), + dotnet.NewCli(realRunner), + alpha.NewFeaturesManagerWithConfig(config.NewEmptyConfig()), + lazy.From[environment.Manager](mockEnv), + ) + + err := i.Initialize(t.Context(), azdCtx, &templates.Template{ + RepositoryPath: localTemplateDir, + }, "") + require.NoError(t, err) + + // .azdignore rules must have been applied even though .gitignore matched .* + require.NoFileExists(t, filepath.Join(projectDir, "CONTRIBUTING.md"), + ".azdignore was not applied — likely filtered out by .gitignore") + require.NoFileExists(t, filepath.Join(projectDir, azdIgnoreFileName)) + require.FileExists(t, filepath.Join(projectDir, "keep.txt")) +} + +// --- .azdignore security tests --- + +func Test_loadAzdIgnore_RejectsSymlink(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("symlink creation requires elevated privileges on Windows") + } + + dir := t.TempDir() + // Create a real file outside the template directory + external := filepath.Join(t.TempDir(), "external-ignore") + require.NoError(t, os.WriteFile(external, []byte("*.secret\n"), 0600)) + + // Symlink .azdignore → external file + require.NoError(t, os.Symlink(external, filepath.Join(dir, azdIgnoreFileName))) + + ig, err := loadAzdIgnore(dir) + require.Error(t, err, "symlink .azdignore should be rejected") + require.Nil(t, ig) + require.Contains(t, err.Error(), "regular file") +} + +func Test_removeAzdIgnoredFiles_PathTraversal(t *testing.T) { + t.Parallel() + + // A malicious .azdignore containing "../" patterns must not remove files + // outside the staging directory. WalkDir only enumerates entries inside + // dir, so traversal patterns simply won't match anything — but we verify + // explicitly that a sibling file survives. + parent := t.TempDir() + secretFile := filepath.Join(parent, "secret.txt") + require.NoError(t, os.WriteFile(secretFile, []byte("top-secret"), 0600)) + + staging := filepath.Join(parent, "staging") + require.NoError(t, os.MkdirAll(staging, 0755)) + require.NoError(t, os.WriteFile( + filepath.Join(staging, "keep.txt"), []byte("keep"), 0600)) + + // .azdignore tries to escape via ../ + azdIgnore := "../secret.txt\n../\n" + require.NoError(t, os.WriteFile( + filepath.Join(staging, azdIgnoreFileName), []byte(azdIgnore), 0600)) + + err := removeAzdIgnoredFiles(staging) + require.NoError(t, err) + + // Sibling file must still exist — no escape happened + require.FileExists(t, secretFile, "path traversal: file outside staging was removed") + // File inside staging is untouched + require.FileExists(t, filepath.Join(staging, "keep.txt")) +} + +func Test_removeAzdIgnoredFiles_EmptyAndCommentOnly(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + azdIgnore string + }{ + {"WhitespaceOnly", " \n\t\n \n"}, + {"CommentsOnly", "# this is a comment\n# another comment\n"}, + {"BlankLines", "\n\n\n"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, "keep.txt"), []byte("keep"), 0600)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "sub"), 0755)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "sub", "also.txt"), []byte("also"), 0600)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, azdIgnoreFileName), []byte(tt.azdIgnore), 0600)) + + err := removeAzdIgnoredFiles(dir) + require.NoError(t, err) + + // All files should survive — no patterns matched + require.FileExists(t, filepath.Join(dir, "keep.txt")) + require.FileExists(t, filepath.Join(dir, "sub", "also.txt")) + // .azdignore itself is still removed + require.NoFileExists(t, filepath.Join(dir, azdIgnoreFileName)) + }) + } +} + +func Test_removeAzdIgnoredFiles_MalformedContent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + azdIgnore []byte + }{ + {"NullBytes", []byte("keep\x00this\n*.log\n")}, + {"BinaryContent", []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}}, // PNG header + {"ControlChars", []byte("\x01\x02\x03\n*.log\n")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, "keep.txt"), []byte("keep"), 0600)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "debug.log"), []byte("log"), 0600)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, azdIgnoreFileName), tt.azdIgnore, 0600)) + + // Must not panic or return error — graceful handling + err := removeAzdIgnoredFiles(dir) + require.NoError(t, err) + + // keep.txt must survive regardless of parse behaviour + require.FileExists(t, filepath.Join(dir, "keep.txt")) + }) + } +} + +func Test_removeAzdIgnoredFiles_OnlyNegationPatterns(t *testing.T) { + t.Parallel() + + // An .azdignore with only negation patterns (no positive rules) should + // not remove anything — negation only "un-ignores" previously matched paths. + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, "a.txt"), []byte("a"), 0600)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "b.txt"), []byte("b"), 0600)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, azdIgnoreFileName), []byte("!a.txt\n!b.txt\n"), 0600)) + + err := removeAzdIgnoredFiles(dir) + require.NoError(t, err) + + require.FileExists(t, filepath.Join(dir, "a.txt")) + require.FileExists(t, filepath.Join(dir, "b.txt")) +} + +func Test_removeAzdIgnoredFiles_UnicodeFilenames(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + // Create files with non-ASCII names + require.NoError(t, os.WriteFile( + filepath.Join(dir, "日本語.txt"), []byte("jp"), 0600)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "café.txt"), []byte("fr"), 0600)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "normal.txt"), []byte("en"), 0600)) + + // .azdignore targets the unicode filename with a glob + azdIgnore := "日本語.txt\ncafé.txt\n" + require.NoError(t, os.WriteFile( + filepath.Join(dir, azdIgnoreFileName), []byte(azdIgnore), 0600)) + + err := removeAzdIgnoredFiles(dir) + require.NoError(t, err) + + require.NoFileExists(t, filepath.Join(dir, "日本語.txt")) + require.NoFileExists(t, filepath.Join(dir, "café.txt")) + require.FileExists(t, filepath.Join(dir, "normal.txt")) +} + +func Test_copyLocalTemplate_SymlinksSkipped(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("symlink creation requires elevated privileges on Windows") + } + + // Create a source template with a symlink pointing outside the template + sourceDir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(sourceDir, "azure.yaml"), []byte("name: test\n"), 0600)) + require.NoError(t, os.WriteFile( + filepath.Join(sourceDir, "real.txt"), []byte("real"), 0600)) + + // Create an external file and symlink to it from inside the template + externalFile := filepath.Join(t.TempDir(), "external-secret.txt") + require.NoError(t, os.WriteFile(externalFile, []byte("secret"), 0600)) + require.NoError(t, os.Symlink(externalFile, filepath.Join(sourceDir, "link.txt"))) + + // Also add .azdignore to confirm the combo works + require.NoError(t, os.WriteFile( + filepath.Join(sourceDir, azdIgnoreFileName), []byte(""), 0600)) + + destDir := t.TempDir() + + realRunner := exec.NewCommandRunner(nil) + i := NewInitializer( + mockinput.NewMockConsole(), + git.NewCli(realRunner), + dotnet.NewCli(realRunner), + alpha.NewFeaturesManagerWithConfig(config.NewEmptyConfig()), + lazy.From[environment.Manager](&mockenv.MockEnvManager{}), + ) + + err := i.copyLocalTemplate(sourceDir, destDir) + require.NoError(t, err) + + // Real file should be copied + require.FileExists(t, filepath.Join(destDir, "real.txt")) + // Symlink should NOT be copied (skipped by OnSymlink handler) + require.NoFileExists(t, filepath.Join(destDir, "link.txt")) +} + +func Test_copyLocalTemplate_SourceIsSymlink(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("symlink creation requires elevated privileges on Windows") + } + + // Create a real template dir and a symlink pointing to it + realDir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(realDir, "azure.yaml"), []byte("name: test\n"), 0600)) + + symlinkDir := filepath.Join(t.TempDir(), "link-to-template") + require.NoError(t, os.Symlink(realDir, symlinkDir)) + + destDir := t.TempDir() + + realRunner := exec.NewCommandRunner(nil) + i := NewInitializer( + mockinput.NewMockConsole(), + git.NewCli(realRunner), + dotnet.NewCli(realRunner), + alpha.NewFeaturesManagerWithConfig(config.NewEmptyConfig()), + lazy.From[environment.Manager](&mockenv.MockEnvManager{}), + ) + + // copyLocalTemplate should reject a symlink as source (TOCTOU mitigation) + err := i.copyLocalTemplate(symlinkDir, destDir) + require.Error(t, err) + require.Contains(t, err.Error(), "symlink") +} + +func Test_removeAzdIgnoredFiles_LargePatternFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + // Create a few real files + require.NoError(t, os.WriteFile( + filepath.Join(dir, "keep.txt"), []byte("keep"), 0600)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "remove-me.log"), []byte("log"), 0600)) + + // Build an .azdignore with 10,000+ patterns — resource exhaustion test. + // Most patterns won't match any file; only "*.log" should take effect. + var sb strings.Builder + for i := range 10000 { + fmt.Fprintf(&sb, "nonexistent-pattern-%d.xyz\n", i) + } + sb.WriteString("*.log\n") + require.NoError(t, os.WriteFile( + filepath.Join(dir, azdIgnoreFileName), []byte(sb.String()), 0600)) + + err := removeAzdIgnoredFiles(dir) + require.NoError(t, err) + + require.FileExists(t, filepath.Join(dir, "keep.txt")) + require.NoFileExists(t, filepath.Join(dir, "remove-me.log")) +} From 8e7ecf454a1030f9c8b531c6e047fbcc3c2929b4 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:34:33 -0700 Subject: [PATCH 2/3] test: add boundary test for 1MB .azdignore size limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses @spboyer review feedback — adds Test_loadAzdIgnore_RejectsOversizedFile to exercise the azdIgnoreMaxSize enforcement path in loadAzdIgnore. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/internal/repository/initializer_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/cli/azd/internal/repository/initializer_test.go b/cli/azd/internal/repository/initializer_test.go index f92488712b3..475af04c094 100644 --- a/cli/azd/internal/repository/initializer_test.go +++ b/cli/azd/internal/repository/initializer_test.go @@ -4,6 +4,7 @@ package repository import ( + "bytes" "context" "fmt" "io/fs" @@ -1955,3 +1956,18 @@ func Test_removeAzdIgnoredFiles_LargePatternFile(t *testing.T) { require.FileExists(t, filepath.Join(dir, "keep.txt")) require.NoFileExists(t, filepath.Join(dir, "remove-me.log")) } + +func Test_loadAzdIgnore_RejectsOversizedFile(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + // Create an .azdignore file that exceeds the 1 MB limit. + data := bytes.Repeat([]byte("x"), azdIgnoreMaxSize+1) + require.NoError(t, os.WriteFile( + filepath.Join(dir, azdIgnoreFileName), data, 0600)) + + ig, err := loadAzdIgnore(dir) + require.Error(t, err) + require.Nil(t, ig) + require.Contains(t, err.Error(), "exceeds maximum size") +} From e7e34c1bea7162ae28271c12b59bff0d1ca9368d Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:30:27 -0700 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20nested=20.azdignore=20cleanup,=20**=20glob=20test,?= =?UTF-8?q?=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Walk staging dir to remove ALL .azdignore files at any depth, not just root - Add RecursiveDoubleStarPattern test for **/node_modules matching - Add Test_removeAzdIgnoredFiles_NestedAzdIgnoreCleanup test - Add cli/azd/docs/azdignore.md template authoring guide Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/docs/azdignore.md | 65 +++++++++++++++++ cli/azd/internal/repository/initializer.go | 20 ++++-- .../internal/repository/initializer_test.go | 69 +++++++++++++++++++ 3 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 cli/azd/docs/azdignore.md diff --git a/cli/azd/docs/azdignore.md b/cli/azd/docs/azdignore.md new file mode 100644 index 00000000000..ee6b03b54b1 --- /dev/null +++ b/cli/azd/docs/azdignore.md @@ -0,0 +1,65 @@ +# .azdignore - Template File Exclusion + +## Overview + +`.azdignore` lets template authors exclude files from being copied when consumers run `azd init`. +Place a `.azdignore` file at the root of your template repository to keep contributor-only files +(CI configs, internal docs, etc.) out of consumer projects. + +## Syntax + +`.azdignore` uses standard `.gitignore` pattern syntax (via [go-gitignore](https://github.com/denormal/go-gitignore)): + +- `*` matches any characters within a path segment +- `**` matches across directory boundaries (recursive) +- `?` matches a single character +- `!pattern` negates a previously matched pattern +- `dirname/` matches a directory and all its contents +- Lines starting with `#` are comments +- Blank lines are ignored + +## Where to Place It + +Place `.azdignore` at the **root** of your template repository, alongside `azure.yaml`. + +Only the root `.azdignore` is processed for ignore rules. Nested `.azdignore` files +(e.g., `docs/.azdignore`) are not processed but are still removed from consumer projects +to keep the output clean. + +## Examples + +A typical `.azdignore` for a template repository: + +```gitignore +# CI/CD files - consumers don't need the template's CI config +.github/ + +# Contributor-only documentation +CONTRIBUTING.md +CODE_OF_CONDUCT.md +docs/internal/ + +# Build artifacts that shouldn't be in templates +**/node_modules +*.log +``` + +### Negation Example + +Exclude all markdown files except `README.md`: + +```gitignore +*.md +!README.md +``` + +## Behavior + +- **Self-removing**: `.azdignore` is always removed from the consumer's project after + processing. Consumers never see the `.azdignore` file. +- **Works with .gitignore**: When initializing from a local template, both `.gitignore` + and `.azdignore` rules apply. `.azdignore` is preserved during the staging copy even if + a `.gitignore` pattern would otherwise exclude it (e.g., `.*`). +- **No-op when absent**: If no `.azdignore` file exists, all template files are copied as usual. +- **Security**: Symlinked `.azdignore` files are rejected. Path traversal patterns (e.g., `../`) + cannot escape the template directory. Files exceeding 1 MB are rejected. diff --git a/cli/azd/internal/repository/initializer.go b/cli/azd/internal/repository/initializer.go index 08e5f3a7fca..b7a9d5fdb40 100644 --- a/cli/azd/internal/repository/initializer.go +++ b/cli/azd/internal/repository/initializer.go @@ -401,10 +401,22 @@ func removeAzdIgnoredFiles(dir string) error { } } - // Always remove .azdignore itself — consumers don't need it. - azdIgnorePath := filepath.Join(dir, azdIgnoreFileName) - if err := os.Remove(azdIgnorePath); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("removing %s file: %w", azdIgnoreFileName, err) + // Remove all .azdignore files — the root one that was just processed and + // any nested ones that templates might contain (e.g., docs/.azdignore). + // Consumers should never see .azdignore files in their project. + err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !d.IsDir() && d.Name() == azdIgnoreFileName { + if removeErr := os.Remove(path); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { + return fmt.Errorf("removing %s file: %w", azdIgnoreFileName, removeErr) + } + } + return nil + }) + if err != nil { + return fmt.Errorf("cleaning nested %s files: %w", azdIgnoreFileName, err) } return nil diff --git a/cli/azd/internal/repository/initializer_test.go b/cli/azd/internal/repository/initializer_test.go index 475af04c094..685c35e7367 100644 --- a/cli/azd/internal/repository/initializer_test.go +++ b/cli/azd/internal/repository/initializer_test.go @@ -1242,6 +1242,30 @@ func Test_removeAzdIgnoredFiles(t *testing.T) { }, expectAbsent: []string{"docs/internal"}, }, + { + name: "RecursiveDoubleStarPattern", + azdIgnore: "**/node_modules\n", + files: []string{ + "README.md", + "src/main.go", + "node_modules/pkg-a/index.js", + "src/node_modules/pkg-b/index.js", + "src/sub/node_modules/pkg-c/index.js", + }, + dirs: []string{ + "node_modules/pkg-a", + "src/node_modules/pkg-b", + "src/sub/node_modules/pkg-c", + }, + expectFiles: []string{ + "README.md", "src/main.go", + }, + expectAbsent: []string{ + "node_modules", + "src/node_modules", + "src/sub/node_modules", + }, + }, } for _, tt := range tests { @@ -1854,6 +1878,51 @@ func Test_removeAzdIgnoredFiles_UnicodeFilenames(t *testing.T) { require.FileExists(t, filepath.Join(dir, "normal.txt")) } +func Test_removeAzdIgnoredFiles_NestedAzdIgnoreCleanup(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + // Create a directory structure with nested .azdignore files + require.NoError(t, os.MkdirAll(filepath.Join(dir, "docs"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "src", "sub"), 0755)) + + require.NoError(t, os.WriteFile( + filepath.Join(dir, "README.md"), []byte("readme"), 0600)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "docs", "guide.md"), []byte("guide"), 0600)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "src", "main.go"), []byte("main"), 0600)) + + // Root .azdignore excludes a file + require.NoError(t, os.WriteFile( + filepath.Join(dir, azdIgnoreFileName), []byte("*.log\n"), 0600)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "debug.log"), []byte("log"), 0600)) + + // Nested .azdignore files that should be cleaned up + require.NoError(t, os.WriteFile( + filepath.Join(dir, "docs", azdIgnoreFileName), []byte("internal/\n"), 0600)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "src", "sub", azdIgnoreFileName), []byte("*.tmp\n"), 0600)) + + err := removeAzdIgnoredFiles(dir) + require.NoError(t, err) + + // Root .azdignore rules applied + require.NoFileExists(t, filepath.Join(dir, "debug.log")) + + // All .azdignore files at every depth must be removed + require.NoFileExists(t, filepath.Join(dir, azdIgnoreFileName)) + require.NoFileExists(t, filepath.Join(dir, "docs", azdIgnoreFileName)) + require.NoFileExists(t, filepath.Join(dir, "src", "sub", azdIgnoreFileName)) + + // Non-ignored content remains + require.FileExists(t, filepath.Join(dir, "README.md")) + require.FileExists(t, filepath.Join(dir, "docs", "guide.md")) + require.FileExists(t, filepath.Join(dir, "src", "main.go")) +} + func Test_copyLocalTemplate_SymlinksSkipped(t *testing.T) { t.Parallel()