Skip to content

Commit 8e9d666

Browse files
committed
fix: return error on non-ErrNotExist stat failures in Tar.Sync()
Previously, Sync() only checked for fs.ErrNotExist when classifying paths into copy vs delete. Non-NotExist stat errors (e.g. EACCES, EIO) caused the condition to be false, falling through to the else clause which incorrectly treated the path as copyable. This masked real errors and led to cryptic failures downstream. Restructure the condition into a three-way branch: - err == nil → copy - ErrNotExist → delete - other errors → return immediately with context This follows the pattern already used by entriesForPath() in the same file. Fixes #13654 Signed-off-by: Lidang Jiang <[email protected]> Signed-off-by: Lidang-Jiang <[email protected]>
1 parent e742d09 commit 8e9d666

File tree

2 files changed

+144
-2
lines changed

2 files changed

+144
-2
lines changed

internal/sync/tar.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,12 @@ func (t *Tar) Sync(ctx context.Context, service string, paths []*PathMapping) er
7373
var pathsToCopy []PathMapping
7474
var pathsToDelete []string
7575
for _, p := range paths {
76-
if _, err := os.Stat(p.HostPath); err != nil && errors.Is(err, fs.ErrNotExist) {
76+
if _, err := os.Stat(p.HostPath); err == nil {
77+
pathsToCopy = append(pathsToCopy, *p)
78+
} else if errors.Is(err, fs.ErrNotExist) {
7779
pathsToDelete = append(pathsToDelete, p.ContainerPath)
7880
} else {
79-
pathsToCopy = append(pathsToCopy, *p)
81+
return fmt.Errorf("stat %q: %w", p.HostPath, err)
8082
}
8183
}
8284

internal/sync/tar_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/*
2+
Copyright 2023 Docker Compose CLI authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
Unless required by applicable law or agreed to in writing, software
9+
distributed under the License is distributed on an "AS IS" BASIS,
10+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
See the License for the specific language governing permissions and
12+
limitations under the License.
13+
*/
14+
15+
package sync
16+
17+
import (
18+
"context"
19+
"io"
20+
"os"
21+
"path/filepath"
22+
"runtime"
23+
"testing"
24+
25+
"github.com/moby/moby/api/types/container"
26+
"github.com/stretchr/testify/assert"
27+
"github.com/stretchr/testify/require"
28+
)
29+
30+
// fakeLowLevelClient records calls made to it for test assertions.
31+
type fakeLowLevelClient struct {
32+
containers []container.Summary
33+
execCmds [][]string
34+
untarCount int
35+
}
36+
37+
func (f *fakeLowLevelClient) ContainersForService(_ context.Context, _ string, _ string) ([]container.Summary, error) {
38+
return f.containers, nil
39+
}
40+
41+
func (f *fakeLowLevelClient) Exec(_ context.Context, _ string, cmd []string, _ io.Reader) error {
42+
f.execCmds = append(f.execCmds, cmd)
43+
return nil
44+
}
45+
46+
func (f *fakeLowLevelClient) Untar(_ context.Context, _ string, _ io.ReadCloser) error {
47+
f.untarCount++
48+
return nil
49+
}
50+
51+
func TestSync_ExistingPath(t *testing.T) {
52+
tmpDir := t.TempDir()
53+
existingFile := filepath.Join(tmpDir, "exists.txt")
54+
require.NoError(t, os.WriteFile(existingFile, []byte("data"), 0o644))
55+
56+
client := &fakeLowLevelClient{
57+
containers: []container.Summary{{ID: "ctr1"}},
58+
}
59+
tar := NewTar("proj", client)
60+
61+
err := tar.Sync(t.Context(), "svc", []*PathMapping{
62+
{HostPath: existingFile, ContainerPath: "/app/exists.txt"},
63+
})
64+
65+
require.NoError(t, err)
66+
assert.Equal(t, 1, client.untarCount, "existing path should be copied via Untar")
67+
assert.Empty(t, client.execCmds, "no delete command expected for existing path")
68+
}
69+
70+
func TestSync_NonExistentPath(t *testing.T) {
71+
client := &fakeLowLevelClient{
72+
containers: []container.Summary{{ID: "ctr1"}},
73+
}
74+
tar := NewTar("proj", client)
75+
76+
err := tar.Sync(t.Context(), "svc", []*PathMapping{
77+
{HostPath: "/no/such/file", ContainerPath: "/app/gone.txt"},
78+
})
79+
80+
require.NoError(t, err)
81+
require.Len(t, client.execCmds, 1, "should issue a delete command")
82+
assert.Equal(t, []string{"rm", "-rf", "/app/gone.txt"}, client.execCmds[0])
83+
}
84+
85+
func TestSync_StatPermissionError(t *testing.T) {
86+
if runtime.GOOS == "windows" {
87+
t.Skip("permission-based test not reliable on Windows")
88+
}
89+
if os.Getuid() == 0 {
90+
t.Skip("test requires non-root to trigger EACCES")
91+
}
92+
93+
tmpDir := t.TempDir()
94+
restrictedDir := filepath.Join(tmpDir, "noaccess")
95+
require.NoError(t, os.Mkdir(restrictedDir, 0o700))
96+
targetFile := filepath.Join(restrictedDir, "secret.txt")
97+
require.NoError(t, os.WriteFile(targetFile, []byte("data"), 0o644))
98+
// Remove all permissions on the parent directory so stat on the child fails with EACCES.
99+
require.NoError(t, os.Chmod(restrictedDir, 0o000))
100+
t.Cleanup(func() {
101+
// Restore permissions so t.TempDir() cleanup can remove it.
102+
_ = os.Chmod(restrictedDir, 0o700)
103+
})
104+
105+
client := &fakeLowLevelClient{
106+
containers: []container.Summary{{ID: "ctr1"}},
107+
}
108+
tar := NewTar("proj", client)
109+
110+
err := tar.Sync(t.Context(), "svc", []*PathMapping{
111+
{HostPath: targetFile, ContainerPath: "/app/secret.txt"},
112+
})
113+
114+
require.Error(t, err)
115+
assert.Contains(t, err.Error(), "permission denied")
116+
assert.Contains(t, err.Error(), "secret.txt")
117+
assert.Equal(t, 0, client.untarCount, "should not attempt copy on stat error")
118+
assert.Empty(t, client.execCmds, "should not attempt delete on stat error")
119+
}
120+
121+
func TestSync_MixedPaths(t *testing.T) {
122+
tmpDir := t.TempDir()
123+
existingFile := filepath.Join(tmpDir, "keep.txt")
124+
require.NoError(t, os.WriteFile(existingFile, []byte("data"), 0o644))
125+
126+
client := &fakeLowLevelClient{
127+
containers: []container.Summary{{ID: "ctr1"}},
128+
}
129+
tar := NewTar("proj", client)
130+
131+
err := tar.Sync(t.Context(), "svc", []*PathMapping{
132+
{HostPath: existingFile, ContainerPath: "/app/keep.txt"},
133+
{HostPath: "/no/such/path", ContainerPath: "/app/removed.txt"},
134+
})
135+
136+
require.NoError(t, err)
137+
assert.Equal(t, 1, client.untarCount, "existing path should be copied")
138+
require.Len(t, client.execCmds, 1)
139+
assert.Contains(t, client.execCmds[0][len(client.execCmds[0])-1], "removed.txt")
140+
}

0 commit comments

Comments
 (0)