diff --git a/cli/azd/pkg/project/project_utils.go b/cli/azd/pkg/project/project_utils.go index ad41fddb341..d91404685e1 100644 --- a/cli/azd/pkg/project/project_utils.go +++ b/cli/azd/pkg/project/project_utils.go @@ -4,6 +4,7 @@ package project import ( + "bytes" "errors" "fmt" "io/fs" @@ -30,12 +31,19 @@ func createDeployableZip(svc *ServiceConfig, root string) (string, error) { ignoreFile := svc.Host.IgnoreFile() var ignorer gitignore.GitIgnore if ignoreFile != "" { - ig, err := gitignore.NewFromFile(filepath.Join(root, ignoreFile)) - if !errors.Is(err, fs.ErrNotExist) && err != nil { + ignoreFilePath := filepath.Join(root, ignoreFile) + contents, err := os.ReadFile(ignoreFilePath) + if errors.Is(err, fs.ErrNotExist) { + // no ignore file, use defaults below + } else if err != nil { + zipFile.Close() + os.Remove(zipFile.Name()) //nolint:gosec // G703: zipFile.Name() is our own temp file, not user-controlled return "", fmt.Errorf("reading ignore file: %w", err) + } else { + // Strip UTF-8 BOM if present, then parse from in-memory contents. + contents = stripUTF8BOM(contents) + ignorer = gitignore.New(bytes.NewReader(contents), root, nil) } - - ignorer = ig } // apply exclusions for zip deployment @@ -68,9 +76,11 @@ func createDeployableZip(svc *ServiceConfig, root string) (string, error) { } // apply exclusions from ignore file - if ignorer != nil && ignorer.Absolute(src, isDir) != nil { - return false, nil - } else if ignorer == nil { // default exclusions without ignorefile control + if ignorer != nil { + if match := ignorer.Absolute(src, isDir); match != nil && match.Ignore() { + return false, nil + } + } else { // default exclusions without ignorefile control if svc.Language == ServiceLanguagePython { if isDir { // check for .venv containing pyvenv.cfg @@ -112,3 +122,12 @@ func createDeployableZip(svc *ServiceConfig, root string) (string, error) { return zipFile.Name(), nil } + +// utf8BOM is the byte order mark that some Windows editors prepend to UTF-8 files. +var utf8BOM = []byte{0xEF, 0xBB, 0xBF} + +// stripUTF8BOM removes a leading UTF-8 BOM from the given byte slice if present. +// The BOM breaks gitignore pattern parsing because the invisible bytes become part of the first pattern. +func stripUTF8BOM(data []byte) []byte { + return bytes.TrimPrefix(data, utf8BOM) +} diff --git a/cli/azd/pkg/project/service_target_functionapp.go b/cli/azd/pkg/project/service_target_functionapp.go index 3f789da1de4..b30558d59fa 100644 --- a/cli/azd/pkg/project/service_target_functionapp.go +++ b/cli/azd/pkg/project/service_target_functionapp.go @@ -44,6 +44,11 @@ func resolveFunctionAppRemoteBuild(serviceConfig *ServiceConfig) (remoteBuild bo return false, fmt.Errorf("reading ignore file: %w", err) } + // Strip UTF-8 BOM if present. Some Windows editors (e.g. Notepad) prepend a BOM which + // causes the gitignore parser to treat the first pattern as having invisible prefix bytes, + // breaking pattern matching. + ignoreFileContents = stripUTF8BOM(ignoreFileContents) + // Parse from in-memory contents so we don't hold an open file handle (important on Windows temp dirs). ignore := gitignore.New(bytes.NewReader(ignoreFileContents), serviceConfig.Path(), nil) diff --git a/cli/azd/pkg/project/service_target_functionapp_test.go b/cli/azd/pkg/project/service_target_functionapp_test.go index 9ea5b1ea4af..5c8bafb0798 100644 --- a/cli/azd/pkg/project/service_target_functionapp_test.go +++ b/cli/azd/pkg/project/service_target_functionapp_test.go @@ -49,64 +49,85 @@ func TestNewFunctionAppTargetTypeValidation(t *testing.T) { } } +// TestResolveFunctionAppRemoteBuild_JavaScriptMatrix covers the full 3×3 matrix of remoteBuild +// (nil, true, false) × funcignore state (excludes node_modules, doesn't exclude, absent). +// Customer case numbers refer to the 9 scenarios reported in the GitHub issue for traceability. func TestResolveFunctionAppRemoteBuild_JavaScriptMatrix(t *testing.T) { t.Parallel() tests := []struct { name string remoteBuild *bool - funcIgnoreContent string + funcIgnoreContent string // empty string = no funcignore file expectRemoteBuild bool expectError string }{ + // Customer Case 1: No flag + funcignore excludes node_modules → auto-detect remote build { - name: "NoRemoteBuildAndFuncIgnoreExcludesNodeModules_RemoteBuildEnabled", + name: "NilRemoteBuild_FuncignoreExcludesNodeModules", remoteBuild: nil, funcIgnoreContent: "node_modules\n", expectRemoteBuild: true, }, + // Customer Case 2: No flag + funcignore doesn't exclude node_modules → local build { - name: "NoRemoteBuildAndFuncIgnoreDoesNotExcludeNodeModules_RemoteBuildDisabled", + name: "NilRemoteBuild_FuncignoreDoesNotExcludeNodeModules", remoteBuild: nil, funcIgnoreContent: "dist\n", expectRemoteBuild: false, }, + // Customer Case 3: No flag + no funcignore → defaults to remote build { - name: "NoRemoteBuildAndMissingFuncIgnore_RemoteBuildEnabled", + name: "NilRemoteBuild_NoFuncignore", remoteBuild: nil, funcIgnoreContent: "", expectRemoteBuild: true, }, + // Customer Case 4: Explicit false + no funcignore → local build { - name: "RemoteBuildFalseAndMissingFuncIgnore_RemoteBuildDisabled", + name: "FalseRemoteBuild_NoFuncignore", remoteBuild: new(false), funcIgnoreContent: "", expectRemoteBuild: false, }, + // Customer Case 5: Explicit false + funcignore excludes node_modules → ERROR + // (remoteBuild: false conflicts with funcignore excluding node_modules) { - name: "RemoteBuildFalseAndFuncIgnoreExcludesNodeModules_Errors", + name: "FalseRemoteBuild_FuncignoreExcludesNodeModules_Errors", remoteBuild: new(false), funcIgnoreContent: "node_modules\n", expectError: "'remoteBuild: false' cannot be used when '.funcignore' excludes node_modules", }, + // Customer Case 6: Explicit false + funcignore doesn't exclude node_modules → local build { - name: "RemoteBuildFalseAndFuncIgnoreDoesNotExcludeNodeModules_Succeeds", + name: "FalseRemoteBuild_FuncignoreDoesNotExcludeNodeModules", remoteBuild: new(false), funcIgnoreContent: "dist\n", expectRemoteBuild: false, }, + // Customer Case 7: Explicit true + funcignore excludes node_modules → remote build { - name: "RemoteBuildTrueAndFuncIgnoreExcludesNodeModules_Succeeds", + name: "TrueRemoteBuild_FuncignoreExcludesNodeModules", remoteBuild: new(true), funcIgnoreContent: "node_modules\n", expectRemoteBuild: true, }, + // Customer Case 8: Explicit true + funcignore doesn't exclude node_modules → ERROR + // (remoteBuild: true requires funcignore to exclude node_modules so server can npm install) { - name: "RemoteBuildTrueAndFuncIgnoreDoesNotExcludeNodeModules_Errors", + name: "TrueRemoteBuild_FuncignoreDoesNotExcludeNodeModules_Errors", remoteBuild: new(true), funcIgnoreContent: "dist\n", expectError: "'remoteBuild: true' requires '.funcignore' to exclude node_modules", }, + // Customer Case 9: Explicit true + no funcignore → remote build + // (hardcoded defaults exclude node_modules, consistent with remoteBuild: true) + { + name: "TrueRemoteBuild_NoFuncignore", + remoteBuild: new(true), + funcIgnoreContent: "", + expectRemoteBuild: true, + }, } for _, tt := range tests { @@ -159,3 +180,237 @@ func TestResolveFunctionAppRemoteBuild_NonJavaScriptDefaults(t *testing.T) { require.NoError(t, err) require.False(t, remoteBuild) } + +func TestResolveFunctionAppRemoteBuild_BOMHandling(t *testing.T) { + t.Parallel() + + // UTF-8 BOM is commonly added by Windows editors (Notepad, etc.) + bom := "\xef\xbb\xbf" + + tests := []struct { + name string + remoteBuild *bool + funcIgnoreContent string + expectRemoteBuild bool + expectError string + }{ + { + name: "BOM_NodeModulesExcluded_NoRemoteBuild", + remoteBuild: nil, + funcIgnoreContent: bom + "node_modules\n", + expectRemoteBuild: true, + }, + { + name: "BOM_NodeModulesExcluded_RemoteBuildTrue", + remoteBuild: new(true), + funcIgnoreContent: bom + "node_modules\n", + expectRemoteBuild: true, + }, + { + name: "BOM_NodeModulesExcluded_RemoteBuildFalse", + remoteBuild: new(false), + funcIgnoreContent: bom + "node_modules\n", + expectError: "'remoteBuild: false' cannot be used when '.funcignore' excludes node_modules", + }, + { + name: "BOM_MultiplePatterns_NodeModulesFirst", + remoteBuild: nil, + funcIgnoreContent: bom + "node_modules\ndist\n.env\n", + expectRemoteBuild: true, + }, + { + name: "BOM_MultiplePatterns_NodeModulesNotFirst", + remoteBuild: nil, + funcIgnoreContent: bom + "dist\nnode_modules\n.env\n", + expectRemoteBuild: true, + }, + { + name: "BOM_OnlyDist_NoNodeModules", + remoteBuild: nil, + funcIgnoreContent: bom + "dist\n", + expectRemoteBuild: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + serviceConfig := createTestServiceConfig(t.TempDir(), AzureFunctionTarget, ServiceLanguageJavaScript) + serviceConfig.RemoteBuild = tt.remoteBuild + + err := os.WriteFile( + filepath.Join(serviceConfig.Path(), ".funcignore"), + []byte(tt.funcIgnoreContent), + 0600, + ) + require.NoError(t, err) + + remoteBuild, err := resolveFunctionAppRemoteBuild(serviceConfig) + if tt.expectError != "" { + require.Error(t, err) + require.ErrorContains(t, err, tt.expectError) + return + } + + require.NoError(t, err) + require.Equal(t, tt.expectRemoteBuild, remoteBuild) + }) + } +} + +func TestStripUTF8BOM(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input []byte + expected []byte + }{ + {"NoBOM", []byte("node_modules\n"), []byte("node_modules\n")}, + {"WithBOM", []byte("\xef\xbb\xbfnode_modules\n"), []byte("node_modules\n")}, + {"EmptyWithBOM", []byte("\xef\xbb\xbf"), []byte{}}, + {"Empty", []byte{}, []byte{}}, + {"PartialBOM", []byte("\xef\xbb"), []byte("\xef\xbb")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := stripUTF8BOM(tt.input) + require.Equal(t, tt.expected, result) + }) + } +} + +// TestResolveFunctionAppRemoteBuild_EdgeCases covers additional edge cases for funcignore patterns. +func TestResolveFunctionAppRemoteBuild_EdgeCases(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + remoteBuild *bool + funcIgnoreContent string + expectRemoteBuild bool + expectError string + }{ + { + name: "EmptyFuncignore_NilRemoteBuild", + remoteBuild: nil, + funcIgnoreContent: "\n", + expectRemoteBuild: false, + }, + { + name: "EmptyFuncignore_TrueRemoteBuild", + remoteBuild: new(true), + funcIgnoreContent: "\n", + expectError: "'remoteBuild: true' requires '.funcignore' to exclude node_modules", + }, + { + name: "EmptyFuncignore_FalseRemoteBuild", + remoteBuild: new(false), + funcIgnoreContent: "\n", + expectRemoteBuild: false, + }, + { + name: "NodeModulesWithTrailingSlash", + remoteBuild: nil, + funcIgnoreContent: "node_modules/\n", + expectRemoteBuild: true, + }, + { + name: "NodeModulesInComment_NotExcluded", + remoteBuild: nil, + funcIgnoreContent: "# node_modules\n", + expectRemoteBuild: false, + }, + { + name: "MultiplePatterns_NodeModulesInMiddle", + remoteBuild: nil, + funcIgnoreContent: "dist\nnode_modules\n.env\n", + expectRemoteBuild: true, + }, + { + name: "CRLFLineEndings", + remoteBuild: nil, + funcIgnoreContent: "dist\r\nnode_modules\r\n.env\r\n", + expectRemoteBuild: true, + }, + { + name: "GlobPattern_NodeModules", + remoteBuild: nil, + funcIgnoreContent: "node_modules/**\n", + expectRemoteBuild: true, + }, + { + name: "LeadingSlash_NodeModules", + remoteBuild: nil, + funcIgnoreContent: "/node_modules\n", + expectRemoteBuild: true, + }, + { + name: "DoubleStarPrefix_NodeModules", + remoteBuild: nil, + funcIgnoreContent: "**/node_modules\n", + expectRemoteBuild: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + serviceConfig := createTestServiceConfig(t.TempDir(), AzureFunctionTarget, ServiceLanguageJavaScript) + serviceConfig.RemoteBuild = tt.remoteBuild + + err := os.WriteFile( + filepath.Join(serviceConfig.Path(), ".funcignore"), + []byte(tt.funcIgnoreContent), + 0600, + ) + require.NoError(t, err) + + remoteBuild, err := resolveFunctionAppRemoteBuild(serviceConfig) + if tt.expectError != "" { + require.Error(t, err) + require.ErrorContains(t, err, tt.expectError) + return + } + + require.NoError(t, err) + require.Equal(t, tt.expectRemoteBuild, remoteBuild) + }) + } +} + +// TestResolveFunctionAppRemoteBuild_TypeScriptParity verifies TypeScript follows the same +// code path as JavaScript for funcignore validation. +func TestResolveFunctionAppRemoteBuild_TypeScriptParity(t *testing.T) { + t.Parallel() + + // TypeScript should behave identically to JavaScript + serviceConfig := createTestServiceConfig(t.TempDir(), AzureFunctionTarget, ServiceLanguageTypeScript) + err := os.WriteFile( + filepath.Join(serviceConfig.Path(), ".funcignore"), + []byte("node_modules\n"), + 0600, + ) + require.NoError(t, err) + + remoteBuild, err := resolveFunctionAppRemoteBuild(serviceConfig) + require.NoError(t, err) + require.True(t, remoteBuild, "TypeScript should auto-detect remoteBuild=true when funcignore excludes node_modules") + + // Also verify error case + serviceConfig2 := createTestServiceConfig(t.TempDir(), AzureFunctionTarget, ServiceLanguageTypeScript) + serviceConfig2.RemoteBuild = new(true) + err = os.WriteFile( + filepath.Join(serviceConfig2.Path(), ".funcignore"), + []byte("dist\n"), + 0600, + ) + require.NoError(t, err) + + _, err = resolveFunctionAppRemoteBuild(serviceConfig2) + require.Error(t, err) + require.ErrorContains(t, err, "'remoteBuild: true' requires '.funcignore' to exclude node_modules") +}