From eec430b6fdfda4f799657ec51b3688faf5fa9991 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 16 Sep 2026 17:13:54 +0200 Subject: [PATCH 1/2] fix: an explicit working dir must survive a remote resource loader LoadConfigFiles defaults the project's working dir to a remote loader's (git, oci) downloaded copy directory whenever config.WorkingDir is still empty -- intentional, so a self-contained remote artifact (extends, bundled env files) resolves against itself. But the check only looked at config.WorkingDir, never at whether the caller's workingDir parameter was itself an explicit request (--project-directory) or just a default (the current directory): an explicit request was silently overridden the same way a default was, with no way to tell the two apart once they reached LoadConfigFiles as a plain string. Options gains workingDirExplicit (set via SetWorkingDirExplicit, mirroring the existing projectName/projectNameImperativelySet pair), and LoadConfigFiles's remote-default assignment now skips itself when that flag is set. cli.ProjectOptions.ReadConfigFiles sets it from options.WorkingDir != "" -- exactly the same condition GetWorkingDir already uses to decide whether an explicit working dir was requested. Fixes docker/compose#14224: `docker compose -f oci://... --project-directory DIR` resolved relative bind mounts against the downloaded artifact's own cache directory instead of DIR. Signed-off-by: Nicolas De Loof --- cli/options.go | 10 ++++- loader/load_config_files_test.go | 77 ++++++++++++++++++++++++++++++++ loader/loader.go | 17 ++++++- 3 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 loader/load_config_files_test.go diff --git a/cli/options.go b/cli/options.go index a169559a..b03942f0 100644 --- a/cli/options.go +++ b/cli/options.go @@ -511,7 +511,15 @@ PATH: // ReadConfigFiles reads ConfigFiles and populates the content field func (o *ProjectOptions) ReadConfigFiles(ctx context.Context, workingDir string, options *ProjectOptions) (*types.ConfigDetails, error) { - config, err := loader.LoadConfigFiles(ctx, options.ConfigPaths, workingDir, options.loadOptions...) + // workingDir already resolved options.WorkingDir with precedence over any + // default (see GetWorkingDir): pass that precedence down explicitly, so a + // remote resource loader (git, oci) does not override it with the + // directory of its own downloaded copy. + explicit := options.WorkingDir != "" + loadOptions := append(append([]func(*loader.Options){}, options.loadOptions...), func(o *loader.Options) { + o.SetWorkingDirExplicit(explicit) + }) + config, err := loader.LoadConfigFiles(ctx, options.ConfigPaths, workingDir, loadOptions...) if err != nil { return nil, err } diff --git a/loader/load_config_files_test.go b/loader/load_config_files_test.go new file mode 100644 index 00000000..2bb1070e --- /dev/null +++ b/loader/load_config_files_test.go @@ -0,0 +1,77 @@ +/* + Copyright 2020 The Compose Specification Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package loader + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "gotest.tools/v3/assert" +) + +// fakeRemoteLoader simulates a remote resource loader (git, oci): it hands +// back a path under its own "download" directory, exactly like a real +// remote loader returns the local copy it fetched the artifact into. +type fakeRemoteLoader struct { + downloadDir string +} + +func (l fakeRemoteLoader) Accept(path string) bool { + return strings.HasPrefix(path, "remote://") +} + +func (l fakeRemoteLoader) Load(_ context.Context, _ string) (string, error) { + return filepath.Join(l.downloadDir, "compose.yaml"), nil +} + +func (l fakeRemoteLoader) Dir(_ string) string { + return l.downloadDir +} + +// TestLoadConfigFilesHonorsExplicitWorkingDir is the docker/compose#14224 +// repro at the compose-go layer: an explicit working dir (--project-directory) +// must survive a remote resource loader, which otherwise defaults the +// working dir to its own downloaded copy's directory so a self-contained +// remote artifact (extends, bundled env files) resolves against itself. +// That default must never override an explicit request. +func TestLoadConfigFilesHonorsExplicitWorkingDir(t *testing.T) { + downloadDir := t.TempDir() + assert.NilError(t, os.WriteFile(filepath.Join(downloadDir, "compose.yaml"), []byte("services: {}"), 0o600)) + remote := fakeRemoteLoader{downloadDir: downloadDir} + + t.Run("explicit working dir is preserved", func(t *testing.T) { + explicitDir := t.TempDir() + config, err := LoadConfigFiles(context.Background(), []string{"remote://ref"}, explicitDir, + func(o *Options) { o.ResourceLoaders = []ResourceLoader{remote} }, + func(o *Options) { o.SetWorkingDirExplicit(true) }, + ) + assert.NilError(t, err) + assert.Equal(t, config.WorkingDir, explicitDir) + }) + + t.Run("defaulted working dir still falls back to the downloaded copy's directory", func(t *testing.T) { + defaultedDir := t.TempDir() + config, err := LoadConfigFiles(context.Background(), []string{"remote://ref"}, defaultedDir, + func(o *Options) { o.ResourceLoaders = []ResourceLoader{remote} }, + ) + assert.NilError(t, err) + assert.Equal(t, config.WorkingDir, downloadDir) + }) +} diff --git a/loader/loader.go b/loader/loader.go index 7f034519..d80087d3 100644 --- a/loader/loader.go +++ b/loader/loader.go @@ -81,6 +81,11 @@ type Options struct { projectName string // Indicates when the projectName was imperatively set or guessed from path projectNameImperativelySet bool + // Indicates the working dir passed to LoadConfigFiles was explicitly + // requested (e.g. --project-directory) rather than defaulted (e.g. the + // current directory), so a remote resource loader (git, oci) must not + // override it with the downloaded copy's own directory + workingDirExplicit bool // Profiles set profiles to enable Profiles []string // SelectedServices restricts the project model to these services (and their dependencies) @@ -209,6 +214,7 @@ func (o *Options) clone() *Options { discardEnvFiles: o.discardEnvFiles, projectName: o.projectName, projectNameImperativelySet: o.projectNameImperativelySet, + workingDirExplicit: o.workingDirExplicit, Profiles: o.Profiles, SelectedServices: o.SelectedServices, PruneUnnecessaryResources: o.PruneUnnecessaryResources, @@ -228,6 +234,15 @@ func (o Options) GetProjectName() (string, bool) { return o.projectName, o.projectNameImperativelySet } +// SetWorkingDirExplicit records whether the working dir passed to +// LoadConfigFiles was explicitly requested by the caller (e.g. +// --project-directory) rather than defaulted (e.g. the current directory). +// A remote resource loader (git, oci) must not override an explicit working +// dir with the directory of its downloaded copy. +func (o *Options) SetWorkingDirExplicit(explicit bool) { + o.workingDirExplicit = explicit +} + // serviceRef identifies a reference to a service. It's used to detect cyclic // references in "extends". type serviceRef struct { @@ -346,7 +361,7 @@ func LoadConfigFiles(ctx context.Context, configFiles []string, workingDir strin if err != nil { return nil, err } - if config.WorkingDir == "" && !isLocalResourceLoader { + if config.WorkingDir == "" && !isLocalResourceLoader && !opts.workingDirExplicit { config.WorkingDir = filepath.Dir(local) } abs, err := filepath.Abs(local) From 8ec34b77c2d9bb1fbd897702483f915df0c9c60a Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Fri, 18 Sep 2026 08:47:51 +0200 Subject: [PATCH 2/2] refactor: rename projectNameImperativelySet to projectNameExplicit This PR introduces workingDirExplicit for the same "caller provided this on purpose, not a default/guess" distinction that the existing projectNameImperativelySet field already captures, but under a different name. Line them up under the same, more idiomatic term: "imperatively set" reads as a contrast between imperative and declarative styles, which isn't the distinction being made here and risks confusing readers about what the flag actually means. "Explicit" says plainly what both fields mean: user-provided, not defaulted. Renames projectNameImperativelySet -> projectNameExplicit and, correspondingly, SetProjectName's imperativelySet parameter -> explicit. SetProjectName is a public API, but the parameter is positional, so no caller is affected. Signed-off-by: Nicolas De Loof --- loader/loader.go | 14 +++++++------- loader/loader_test.go | 12 ++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/loader/loader.go b/loader/loader.go index d80087d3..3d4a306d 100644 --- a/loader/loader.go +++ b/loader/loader.go @@ -79,8 +79,8 @@ type Options struct { discardEnvFiles bool // Set project projectName projectName string - // Indicates when the projectName was imperatively set or guessed from path - projectNameImperativelySet bool + // Indicates when the projectName was explicitly set or guessed from path + projectNameExplicit bool // Indicates the working dir passed to LoadConfigFiles was explicitly // requested (e.g. --project-directory) rather than defaulted (e.g. the // current directory), so a remote resource loader (git, oci) must not @@ -213,7 +213,7 @@ func (o *Options) clone() *Options { Interpolate: o.Interpolate, discardEnvFiles: o.discardEnvFiles, projectName: o.projectName, - projectNameImperativelySet: o.projectNameImperativelySet, + projectNameExplicit: o.projectNameExplicit, workingDirExplicit: o.workingDirExplicit, Profiles: o.Profiles, SelectedServices: o.SelectedServices, @@ -225,13 +225,13 @@ func (o *Options) clone() *Options { } } -func (o *Options) SetProjectName(name string, imperativelySet bool) { +func (o *Options) SetProjectName(name string, explicit bool) { o.projectName = name - o.projectNameImperativelySet = imperativelySet + o.projectNameExplicit = explicit } func (o Options) GetProjectName() (string, bool) { - return o.projectName, o.projectNameImperativelySet + return o.projectName, o.projectNameExplicit } // SetWorkingDirExplicit records whether the working dir passed to @@ -726,7 +726,7 @@ func projectName(details *types.ConfigDetails, opts *Options) error { details.Environment[consts.ComposeProjectName] = opts.projectName }() - if opts.projectNameImperativelySet { + if opts.projectNameExplicit { if NormalizeProjectName(opts.projectName) != opts.projectName { return InvalidProjectNameErr(opts.projectName) } diff --git a/loader/loader_test.go b/loader/loader_test.go index c8b9e38e..75aaecb6 100644 --- a/loader/loader_test.go +++ b/loader/loader_test.go @@ -2785,20 +2785,20 @@ func TestLoadProjectName(t *testing.T) { wantErr: "project name must not be empty", }, { - name: "project name from options, not imperatively set; no env", + name: "project name from options, not explicitly set; no env", options: withProjectName(projectName, false), }, { - name: "project name from options, imperatively set; no env", + name: "project name from options, explicitly set; no env", options: withProjectName(projectName, true), }, { - name: "project name from options, not imperatively set; empty env", + name: "project name from options, not explicitly set; empty env", env: map[string]string{}, options: withProjectName(projectName, false), }, { - name: "project name from options, imperatively set; empty env", + name: "project name from options, explicitly set; empty env", env: map[string]string{}, options: withProjectName(projectName, true), }, @@ -2828,9 +2828,9 @@ services: } } -func withProjectName(projectName string, imperativelySet bool) func(*Options) { +func withProjectName(projectName string, explicit bool) func(*Options) { return func(opts *Options) { - opts.SetProjectName(projectName, imperativelySet) + opts.SetProjectName(projectName, explicit) } }