Skip to content

Commit 58838e8

Browse files
authored
campaigns: widen permissions on mounted paths (#366)
This fixes #365 by ensuring that files and workspaces mounted into campaign containers are world readable, writable, and executable as appropriate.
1 parent 824f170 commit 58838e8

File tree

6 files changed

+321
-2
lines changed

6 files changed

+321
-2
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ All notable changes to `src-cli` are documented in this file.
1717

1818
### Fixed
1919

20+
- Campaign steps run in a container that does not run as root could fail on systems that do not map the running user ID to the container, most notably desktop Linux. This has been fixed: temporary files and workspaces mounted into the container now have sufficient permissions to allow the container user to execute the step. [#366](https://github.com/sourcegraph/src-cli/pull/366)
21+
2022
## 3.21.5
2123

2224
### Added

internal/campaigns/archive_fetcher.go

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"path/filepath"
1313
"strings"
1414

15+
"github.com/hashicorp/go-multierror"
1516
"github.com/pkg/errors"
1617
"github.com/sourcegraph/src-cli/internal/api"
1718
"github.com/sourcegraph/src-cli/internal/campaigns/graphql"
@@ -67,6 +68,11 @@ func unzipToTempDir(ctx context.Context, zipFile, tempDir, tempFilePrefix string
6768
if err != nil {
6869
return "", err
6970
}
71+
72+
if err := os.Chmod(volumeDir, 0777); err != nil {
73+
return "", err
74+
}
75+
7076
return volumeDir, unzip(zipFile, volumeDir)
7177
}
7278

@@ -86,6 +92,10 @@ func fetchRepositoryArchive(ctx context.Context, client api.Client, repo *graphq
8692
return fmt.Errorf("unable to fetch archive (HTTP %d from %s)", resp.StatusCode, req.URL.String())
8793
}
8894

95+
// Unlike the mkdirAll() calls elsewhere in this file, this is only giving
96+
// us a temporary place on the filesystem to keep the archive. Since it's
97+
// never mounted into the containers being run, we can keep these
98+
// directories 0700 without issue.
8999
if err := os.MkdirAll(filepath.Dir(dest), 0700); err != nil {
90100
return err
91101
}
@@ -130,13 +140,13 @@ func unzip(zipFile, dest string) error {
130140
}
131141

132142
if f.FileInfo().IsDir() {
133-
if err := os.MkdirAll(fpath, os.ModePerm); err != nil {
143+
if err := mkdirAll(dest, f.Name, 0777); err != nil {
134144
return err
135145
}
136146
continue
137147
}
138148

139-
if err := os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
149+
if err := mkdirAll(dest, filepath.Dir(f.Name), 0777); err != nil {
140150
return err
141151
}
142152

@@ -145,6 +155,16 @@ func unzip(zipFile, dest string) error {
145155
return err
146156
}
147157

158+
// Since the container might not run as the same user, we need to ensure
159+
// that the file is globally writable. If the execute bit is normally
160+
// set on the zipped up file, let's ensure we propagate that to the
161+
// group and other permission bits too.
162+
if f.Mode()&0111 != 0 {
163+
outFile.Chmod(0777)
164+
} else {
165+
outFile.Chmod(0666)
166+
}
167+
148168
rc, err := f.Open()
149169
if err != nil {
150170
outFile.Close()
@@ -166,3 +186,61 @@ func unzip(zipFile, dest string) error {
166186

167187
return nil
168188
}
189+
190+
// Technically, this is a misnomer, since it might be a socket or block special,
191+
// but errPathExistsAsNonDir is just ugly for an internal type.
192+
type errPathExistsAsFile string
193+
194+
var _ error = errPathExistsAsFile("")
195+
196+
func (e errPathExistsAsFile) Error() string {
197+
return fmt.Sprintf("path already exists, but not as a directory: %s", string(e))
198+
}
199+
200+
// mkdirAll is essentially os.MkdirAll(filepath.Join(base, path), perm), but
201+
// applies the given permission regardless of the user's umask.
202+
func mkdirAll(base, path string, perm os.FileMode) error {
203+
abs := filepath.Join(base, path)
204+
205+
// Create the directory if it doesn't exist.
206+
st, err := os.Stat(abs)
207+
if err != nil {
208+
// It's expected that we'll get an error if the directory doesn't exist,
209+
// so let's check that it's of the type we expect.
210+
if !os.IsNotExist(err) {
211+
return err
212+
}
213+
214+
// Now we're clear to create the directory.
215+
if err := os.MkdirAll(abs, perm); err != nil {
216+
return err
217+
}
218+
} else if !st.IsDir() {
219+
// The file/socket/whatever exists, but it's not a directory. That's
220+
// definitely going to be an issue.
221+
return errPathExistsAsFile(abs)
222+
}
223+
224+
// If os.MkdirAll() was invoked earlier, then the permissions it set were
225+
// subject to the umask. Let's walk the directories we may or may not have
226+
// created and ensure their permissions look how we want.
227+
return ensureAll(base, path, perm)
228+
}
229+
230+
// ensureAll ensures that all directories under path have the expected
231+
// permissions.
232+
func ensureAll(base, path string, perm os.FileMode) error {
233+
var errs *multierror.Error
234+
235+
// In plain English: for each directory in the path parameter, we should
236+
// chmod that path to the permissions that are expected.
237+
acc := []string{base}
238+
for _, element := range strings.Split(path, string(os.PathSeparator)) {
239+
acc = append(acc, element)
240+
if err := os.Chmod(filepath.Join(acc...), perm); err != nil {
241+
errs = multierror.Append(errs, err)
242+
}
243+
}
244+
245+
return errs.ErrorOrNil()
246+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// +build !windows
2+
3+
package campaigns
4+
5+
import (
6+
"os"
7+
"path/filepath"
8+
"testing"
9+
10+
"github.com/pkg/errors"
11+
)
12+
13+
func TestMkdirAllStatError(t *testing.T) {
14+
// This test can't be trivially reproduced on Windows, so we just won't run
15+
// it there.
16+
17+
// Create a shared workspace.
18+
base := mustCreateWorkspace(t)
19+
defer os.RemoveAll(base)
20+
21+
// We'll create a directory and a file within it, remove the execute bit on
22+
// the directory, and then stat() the file to cause a failure.
23+
if err := os.MkdirAll(filepath.Join(base, "locked"), 0700); err != nil {
24+
t.Fatal(err)
25+
}
26+
27+
f, err := os.Create(filepath.Join(base, "locked", "file"))
28+
if err != nil {
29+
t.Fatal(err)
30+
}
31+
f.Close()
32+
33+
if err := os.Chmod(filepath.Join(base, "locked"), 0600); err != nil {
34+
t.Fatal(err)
35+
}
36+
37+
err = mkdirAll(base, "locked/file", 0750)
38+
if err == nil {
39+
t.Errorf("unexpected nil error")
40+
} else if _, ok := err.(errPathExistsAsFile); ok {
41+
t.Errorf("unexpected error of type %T: %v", err, err)
42+
}
43+
44+
}
45+
46+
func mustHavePerm(t *testing.T, path string, want os.FileMode) error {
47+
t.Helper()
48+
49+
if have := mustGetPerm(t, path); have != want {
50+
return errors.Errorf("unexpected permissions: have=%o want=%o", have, want)
51+
}
52+
return nil
53+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package campaigns
2+
3+
import (
4+
"io/ioutil"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
)
9+
10+
func TestMkdirAll(t *testing.T) {
11+
// TestEnsureAll does most of the heavy lifting here; we're just testing the
12+
// MkdirAll scenarios here around whether the directory exists.
13+
14+
// Create a shared workspace.
15+
base := mustCreateWorkspace(t)
16+
defer os.RemoveAll(base)
17+
18+
t.Run("directory exists", func(t *testing.T) {
19+
if err := os.MkdirAll(filepath.Join(base, "exist"), 0755); err != nil {
20+
t.Fatal(err)
21+
}
22+
23+
if err := mkdirAll(base, "exist", 0750); err != nil {
24+
t.Errorf("unexpected non-nil error: %v", err)
25+
}
26+
27+
if err := mustHavePerm(t, filepath.Join(base, "exist"), 0750); err != nil {
28+
t.Error(err)
29+
}
30+
31+
if !isDir(t, filepath.Join(base, "exist")) {
32+
t.Error("not a directory")
33+
}
34+
})
35+
36+
t.Run("directory does not exist", func(t *testing.T) {
37+
if err := mkdirAll(base, "new", 0750); err != nil {
38+
t.Errorf("unexpected non-nil error: %v", err)
39+
}
40+
41+
if err := mustHavePerm(t, filepath.Join(base, "new"), 0750); err != nil {
42+
t.Error(err)
43+
}
44+
45+
if !isDir(t, filepath.Join(base, "new")) {
46+
t.Error("not a directory")
47+
}
48+
})
49+
50+
t.Run("directory exists, but is not a directory", func(t *testing.T) {
51+
f, err := os.Create(filepath.Join(base, "file"))
52+
if err != nil {
53+
t.Fatal(err)
54+
}
55+
f.Close()
56+
57+
err = mkdirAll(base, "file", 0750)
58+
if _, ok := err.(errPathExistsAsFile); !ok {
59+
t.Errorf("unexpected error of type %T: %v", err, err)
60+
}
61+
})
62+
}
63+
64+
func TestEnsureAll(t *testing.T) {
65+
// Create a workspace.
66+
base := mustCreateWorkspace(t)
67+
defer os.RemoveAll(base)
68+
69+
// Create three nested directories with 0700 permissions. We'll use Chmod
70+
// explicitly to avoid any umask issues.
71+
if err := os.MkdirAll(filepath.Join(base, "a", "b", "c"), 0700); err != nil {
72+
t.Fatal(err)
73+
}
74+
dirs := []string{
75+
filepath.Join(base, "a"),
76+
filepath.Join(base, "a", "b"),
77+
filepath.Join(base, "a", "b", "c"),
78+
}
79+
for _, dir := range dirs {
80+
if err := os.Chmod(dir, 0700); err != nil {
81+
t.Fatal(err)
82+
}
83+
}
84+
85+
// Now we'll set them to 0750 and see what happens.
86+
if err := ensureAll(base, "a/b/c", 0750); err != nil {
87+
t.Errorf("unexpected non-nil error: %v", err)
88+
}
89+
for _, dir := range dirs {
90+
if err := mustHavePerm(t, dir, 0750); err != nil {
91+
t.Error(err)
92+
}
93+
}
94+
if err := mustHavePerm(t, base, 0700); err != nil {
95+
t.Error(err)
96+
}
97+
98+
// Finally, let's ensure we get an error when we try to ensure a directory
99+
// that doesn't exist.
100+
if err := ensureAll(base, "d", 0750); err == nil {
101+
t.Errorf("unexpected nil error")
102+
}
103+
}
104+
105+
func mustCreateWorkspace(t *testing.T) string {
106+
base, err := ioutil.TempDir("", "")
107+
if err != nil {
108+
t.Fatal(err)
109+
}
110+
111+
// We'll explicitly set the base workspace to 0700 so we have a known
112+
// environment for testing.
113+
if err := os.Chmod(base, 0700); err != nil {
114+
t.Fatal(err)
115+
}
116+
117+
return base
118+
}
119+
120+
func mustGetPerm(t *testing.T, file string) os.FileMode {
121+
t.Helper()
122+
123+
st, err := os.Stat(file)
124+
if err != nil {
125+
t.Fatal(err)
126+
}
127+
128+
// We really only need the lower bits here.
129+
return st.Mode() & 0777
130+
}
131+
132+
func isDir(t *testing.T, path string) bool {
133+
t.Helper()
134+
135+
st, err := os.Stat(path)
136+
if err != nil {
137+
t.Fatal(err)
138+
}
139+
140+
return st.IsDir()
141+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package campaigns
2+
3+
import (
4+
"os"
5+
"testing"
6+
7+
"github.com/pkg/errors"
8+
)
9+
10+
func mustHavePerm(t *testing.T, path string, want os.FileMode) error {
11+
t.Helper()
12+
13+
have := mustGetPerm(t, path)
14+
15+
// Go maps Windows file attributes onto Unix permissions in a fairly trivial
16+
// way: readonly files will be 0444, normal files will be 0666, and
17+
// directories will have 0111 or-ed onto that value. The end. Source:
18+
// https://sourcegraph.com/github.com/golang/go@fd841f65368906923e287afab91857043036459d/-/blob/src/os/types_windows.go#L112-134
19+
if want&0222 != 0 {
20+
want = 0666
21+
} else {
22+
want = 0444
23+
}
24+
if isDir(t, path) {
25+
want = want | 0111
26+
}
27+
28+
if have != want {
29+
return errors.Errorf("unexpected permissions: have=%o want=%o", have, want)
30+
}
31+
return nil
32+
}

internal/campaigns/run_steps.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,19 @@ func runSteps(ctx context.Context, wc *WorkspaceCreator, repo *graphql.Repositor
112112
return nil, errors.Wrap(err, "closing temporary file")
113113
}
114114

115+
// This file needs to be readable within the container regardless of the
116+
// user the container is running as, so we'll set the appropriate group
117+
// and other bits to make it so.
118+
//
119+
// A fun note: although os.File exposes a Chmod() method, we can't
120+
// unconditionally use it because Windows cannot change the attributes
121+
// of an open file. Rather than going to the trouble of having
122+
// conditionally compiled files here, instead we'll just wait until the
123+
// file is closed to twiddle the permission bits. Which is now!
124+
if err := os.Chmod(runScriptFile.Name(), 0644); err != nil {
125+
return nil, errors.Wrap(err, "setting permissions on the temporary file")
126+
}
127+
115128
// Parse and render the step.Files.
116129
files, err := renderStepFiles(step.Files, &stepContext)
117130
if err != nil {

0 commit comments

Comments
 (0)