diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go index 2b4bcb638ee..8795451e408 100644 --- a/cmd/compose/compose.go +++ b/cmd/compose/compose.go @@ -298,6 +298,10 @@ func (o *ProjectOptions) ToModel(ctx context.Context, dockerCli command.Cli, ser return nil, err } + if err := checkConfigPathsNotDirectories(options.ConfigPaths, remotes); err != nil { + return nil, err + } + if o.Compatibility || utils.StringToBool(options.Environment[ComposeCompatibility]) { api.Separator = "_" } @@ -305,6 +309,34 @@ func (o *ProjectOptions) ToModel(ctx context.Context, dockerCli command.Cli, ser return options.LoadModel(ctx) } +// checkConfigPathsNotDirectories returns an error if any local config path is a +// directory rather than a file. Remote resource paths and stdin ("-") are skipped. +// +// This guards against COMPOSE_FILE being set to a directory (e.g. COMPOSE_FILE="" +// which filepath.Abs resolves to the working directory). +func checkConfigPathsNotDirectories(configPaths []string, remoteLoaders []loader.ResourceLoader) error { + for _, configPath := range configPaths { + if configPath == "-" { + continue + } + isRemote := false + for _, r := range remoteLoaders { + if r.Accept(configPath) { + isRemote = true + break + } + } + if isRemote { + continue + } + if info, err := os.Stat(configPath); err == nil && info.IsDir() { + return fmt.Errorf("path %q is a directory, not a Compose file; "+ + "check the COMPOSE_FILE environment variable or the -f flag", configPath) + } + } + return nil +} + // ToProject loads a Compose project using the LoadProject API. // Accepts optional cli.ProjectOptionsFn to control loader behavior. func (o *ProjectOptions) ToProject(ctx context.Context, dockerCli command.Cli, backend api.Compose, services []string, po ...cli.ProjectOptionsFn) (*types.Project, tracing.Metrics, error) { diff --git a/pkg/compose/loader.go b/pkg/compose/loader.go index 9a0699da7c6..169fd74d515 100644 --- a/pkg/compose/loader.go +++ b/pkg/compose/loader.go @@ -19,6 +19,7 @@ package compose import ( "context" "errors" + "fmt" "os" "strings" @@ -31,6 +32,36 @@ import ( "github.com/docker/compose/v5/pkg/utils" ) +// checkConfigPathsForDirectories returns an error if any config path in configPaths +// is a local directory instead of a file. Remote paths (accepted by remoteLoaders) +// and the special "-" (stdin) value are skipped. +// +// This provides a clear error when COMPOSE_FILE is set to a directory path (e.g., +// "COMPOSE_FILE=" resolves to the working directory via filepath.Abs("")). +func checkConfigPathsForDirectories(configPaths []string, remoteLoaders []loader.ResourceLoader) error { + for _, configPath := range configPaths { + if configPath == "-" { + continue + } + isRemote := false + for _, r := range remoteLoaders { + if r.Accept(configPath) { + isRemote = true + break + } + } + if isRemote { + continue + } + info, err := os.Stat(configPath) + if err == nil && info.IsDir() { + return fmt.Errorf("path %q is a directory, not a Compose file; "+ + "check the COMPOSE_FILE environment variable or the -f flag", configPath) + } + } + return nil +} + // LoadProject implements api.Compose.LoadProject // It loads and validates a Compose project from configuration files. func (s *composeService) LoadProject(ctx context.Context, options api.ProjectLoadOptions) (*types.Project, error) { @@ -42,6 +73,10 @@ func (s *composeService) LoadProject(ctx context.Context, options api.ProjectLoa return nil, err } + if err := checkConfigPathsForDirectories(projectOptions.ConfigPaths, remoteLoaders); err != nil { + return nil, err + } + // Register all user-provided listeners (e.g., for metrics collection) for _, listener := range options.LoadListeners { if listener != nil { diff --git a/pkg/compose/loader_test.go b/pkg/compose/loader_test.go index 8006d4169d0..9b20322ad11 100644 --- a/pkg/compose/loader_test.go +++ b/pkg/compose/loader_test.go @@ -320,3 +320,25 @@ func TestLoadProject_MissingComposeFile(t *testing.T) { require.Error(t, err) assert.Nil(t, project) } + +func TestLoadProject_DirectoryAsComposeFile(t *testing.T) { + // Reproduce the misleading error described in https://github.com/docker/compose/issues/13649: + // when COMPOSE_FILE is set to a directory (e.g. COMPOSE_FILE="" resolves to the working + // directory via filepath.Abs("")), the error "read : is a directory" was shown. + // The fix should return a clear error message instead. + tmpDir := t.TempDir() + + service, err := NewComposeService(nil) + require.NoError(t, err) + + project, err := service.LoadProject(t.Context(), api.ProjectLoadOptions{ + ConfigPaths: []string{tmpDir}, + }) + + require.Error(t, err) + assert.Nil(t, project) + assert.Contains(t, err.Error(), "is a directory") + assert.Contains(t, err.Error(), "Compose file") + // Ensure the old opaque error message is NOT present + assert.NotContains(t, err.Error(), "read "+tmpDir+": is a directory") +}