Browse code

Fix parsing of user/group during copy operation

If a container was started with

- a numeric uid
- both a user and group (username:groupname)
- uid and gid (uid:gid)

The copy action failed, because the "username:groupname"
was looked up using getent.

This patch;

- splits `user` and `group` before looking up
- if numeric `uid` or `gid` is used and lookup fails,
the `uid` / `gid` is used as-is

The code also looked up the user and group on the host
instead of in the container when using getent; this patch
fixes the lookup to only use the container's /etc/passwd
and /etc/group instead.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>

Sebastiaan van Stijn authored on 2017/07/18 02:16:50
Showing 2 changed files
... ...
@@ -3,26 +3,130 @@
3 3
 package daemon // import "github.com/docker/docker/daemon"
4 4
 
5 5
 import (
6
+	"fmt"
7
+	"math"
8
+	"strconv"
9
+	"strings"
10
+
6 11
 	"github.com/docker/docker/container"
7
-	"github.com/docker/docker/internal/usergroup"
12
+	"github.com/docker/docker/errdefs"
8 13
 	"github.com/docker/docker/pkg/archive"
9 14
 	"github.com/docker/docker/pkg/idtools"
15
+	"github.com/moby/sys/user"
10 16
 )
11 17
 
12
-func (daemon *Daemon) tarCopyOptions(container *container.Container, noOverwriteDirNonDir bool) (*archive.TarOptions, error) {
13
-	if container.Config.User == "" {
18
+func (daemon *Daemon) tarCopyOptions(ctr *container.Container, noOverwriteDirNonDir bool) (*archive.TarOptions, error) {
19
+	if ctr.Config.User == "" {
14 20
 		return daemon.defaultTarCopyOptions(noOverwriteDirNonDir), nil
15 21
 	}
16 22
 
17
-	user, err := usergroup.LookupUser(container.Config.User)
23
+	uid, gid, err := getUIDGID(ctr.Config.User)
18 24
 	if err != nil {
19
-		return nil, err
25
+		return nil, errdefs.InvalidParameter(err)
20 26
 	}
21 27
 
22
-	identity := idtools.Identity{UID: user.Uid, GID: user.Gid}
23
-
24 28
 	return &archive.TarOptions{
25 29
 		NoOverwriteDirNonDir: noOverwriteDirNonDir,
26
-		ChownOpts:            &identity,
30
+		ChownOpts:            &idtools.Identity{UID: uid, GID: gid},
27 31
 	}, nil
28 32
 }
33
+
34
+// getUIDGID resolves the UID and GID of a given container's Config.User,
35
+// which can contain a user name (or ID) and, optionally, group (or ID).
36
+//
37
+// usergrp is a username or uid, and optional group, in the format `user[:group]`.
38
+// Both `user` and `group` can be provided as an `uid` / `gid`, so the following
39
+// formats are supported:
40
+//
41
+// - username            - valid username from /etc/passwd
42
+// - username:groupname  - valid username; valid groupname from /etc/passwd, /etc/group
43
+// - uid                 - 32-bit unsigned int valid Linux UID value
44
+// - uid:gid             - uid value; 32-bit unsigned int Linux GID value
45
+// - username:gid        - valid username from getent(1), gid value; 32-bit unsigned int Linux GID value
46
+// - uid:groupname       - 32-bit unsigned int valid Linux UID value, valid groupname from /etc/group
47
+//
48
+// If only a username (or uid) is provided, an attempt is made to look up the gid
49
+// for that username using /etc/passwd
50
+func getUIDGID(ctrUser string) (uid int, gid int, _ error) {
51
+	userNameOrID, groupNameOrID, _ := strings.Cut(ctrUser, ":")
52
+
53
+	// Align with behavior of docker run, which treats an empty username
54
+	// or groupname as default (0 (root)).
55
+	//
56
+	//	docker run --rm --user ":33" alpine id
57
+	//	uid=0(root) gid=33 groups=33
58
+	//
59
+	//	docker run --rm --user "33:" alpine id
60
+	//	uid=33 gid=0(root) groups=0(root)
61
+	if userNameOrID != "" {
62
+		var err error
63
+		uid, gid, err = lookupUser(userNameOrID)
64
+		if err != nil {
65
+			return 0, 0, err
66
+		}
67
+	}
68
+	if groupNameOrID != "" {
69
+		var err error
70
+		gid, err = lookupGID(groupNameOrID)
71
+		if err != nil {
72
+			return 0, 0, err
73
+		}
74
+	}
75
+	return uid, gid, nil
76
+}
77
+
78
+// getIDOrName checks whether nameOrID is a ID (integer) or a Name.
79
+// It assumes nameOrID is a name when failing to parse as integer,
80
+// in which case a non-empty name is returned.
81
+func getIDOrName(nameOrID string) (id int, name string) {
82
+	if uid, err := strconv.ParseUint(nameOrID, 10, 32); err == nil && uid <= math.MaxInt32 {
83
+		// uid provided
84
+		return int(uid), ""
85
+	}
86
+	// not an id, assume name
87
+	return 0, nameOrID
88
+}
89
+
90
+func lookupUser(nameOrID string) (uid, gid int, _ error) {
91
+	userID, userName := getIDOrName(nameOrID)
92
+	if userName != "" {
93
+		u, err := user.LookupUser(userName)
94
+		if err != nil {
95
+			return 0, 0, fmt.Errorf("failed to look up user %q in container: %w", userName, err)
96
+		}
97
+		return u.Uid, u.Gid, nil
98
+	}
99
+
100
+	u, err := user.LookupUid(userID)
101
+	if err != nil {
102
+		// Match behavior of "docker run": when using a UID for the
103
+		// user, resolving the user and its primary group is best-effort.
104
+		// If a user with the given UID is found, we use its primary
105
+		// group, otherwise use it as-is and use the default (0) as
106
+		//  GID.
107
+		//
108
+		//	docker run --rm --user 12345 ubuntu id
109
+		//	uid=12345 gid=0(root) groups=0(root)
110
+		//
111
+		//	docker run --rm ubuntu cat /etc/passwd | grep www-data
112
+		//	www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
113
+		//
114
+		//	docker run --rm --user 33 ubuntu id
115
+		//	uid=33(www-data) gid=33(www-data) groups=33(www-data)
116
+		return userID, 0, nil
117
+	}
118
+	return u.Uid, u.Gid, nil
119
+}
120
+
121
+func lookupGID(nameOrID string) (int, error) {
122
+	groupID, groupName := getIDOrName(nameOrID)
123
+	if groupName == "" {
124
+		// GID is passed, no need to look up
125
+		return groupID, nil
126
+	}
127
+	group, err := user.LookupGroup(groupName)
128
+	if err != nil {
129
+		return 0, fmt.Errorf("failed to look up group %q in container: %w", nameOrID, err)
130
+	}
131
+	return group.Gid, nil
132
+}
... ...
@@ -3,10 +3,12 @@ package container // import "github.com/docker/docker/integration/container"
3 3
 import (
4 4
 	"archive/tar"
5 5
 	"bytes"
6
+	"context"
6 7
 	"encoding/json"
7 8
 	"io"
8 9
 	"os"
9 10
 	"path/filepath"
11
+	"strings"
10 12
 	"testing"
11 13
 
12 14
 	"github.com/docker/docker/api/types"
... ...
@@ -89,6 +91,124 @@ func TestCopyEmptyFile(t *testing.T) {
89 89
 	defer rdr.Close()
90 90
 }
91 91
 
92
+func TestCopyToContainerCopyUIDGID(t *testing.T) {
93
+	skip.If(t, testEnv.DaemonInfo.OSType == "windows")
94
+	ctx := setupTest(t)
95
+
96
+	apiClient := testEnv.APIClient()
97
+	imageID := makeTestImage(ctx, t)
98
+
99
+	tests := []struct {
100
+		doc      string
101
+		user     string
102
+		expected string
103
+	}{
104
+		{
105
+			doc:      "image default",
106
+			expected: "2375:2376",
107
+		},
108
+		{
109
+			// Align with behavior of docker run, which treats a UID with
110
+			// empty groupname as default (0 (root)).
111
+			//
112
+			//	docker run --rm --user "7777:" alpine id
113
+			//	uid=7777 gid=0(root) groups=0(root)
114
+			doc:      "trailing colon",
115
+			user:     "7777:",
116
+			expected: "7777:0",
117
+		},
118
+		{
119
+			// Align with behavior of docker run, which treats a GID with
120
+			// empty username as default (0 (root)).
121
+			//
122
+			//	docker run --rm --user ":7777" alpine id
123
+			//	uid=0(root) gid=7777 groups=7777
124
+			doc:      "leading colon",
125
+			user:     ":7777",
126
+			expected: "0:7777",
127
+		},
128
+		{
129
+			doc:      "known UID",
130
+			user:     "2375",
131
+			expected: "2375:2376",
132
+		},
133
+		{
134
+			doc:      "unknown UID",
135
+			user:     "7777",
136
+			expected: "7777:0",
137
+		},
138
+		{
139
+			doc:      "UID and GID",
140
+			user:     "2375:2376",
141
+			expected: "2375:2376",
142
+		},
143
+		{
144
+			doc:      "username and groupname",
145
+			user:     "testuser:testgroup",
146
+			expected: "2375:2376",
147
+		},
148
+		{
149
+			doc:      "username",
150
+			user:     "testuser",
151
+			expected: "2375:2376",
152
+		},
153
+		{
154
+			doc:      "username and GID",
155
+			user:     "testuser:7777",
156
+			expected: "2375:7777",
157
+		},
158
+		{
159
+			doc:      "UID and groupname",
160
+			user:     "7777:testgroup",
161
+			expected: "7777:2376",
162
+		},
163
+	}
164
+
165
+	for _, tc := range tests {
166
+		t.Run(tc.doc, func(t *testing.T) {
167
+			cID := container.Run(ctx, t, apiClient, container.WithImage(imageID), container.WithUser(tc.user))
168
+			defer container.Remove(ctx, t, apiClient, cID, containertypes.RemoveOptions{Force: true})
169
+
170
+			// tar with empty file
171
+			dstDir, preparedArchive := makeEmptyArchive(t)
172
+			err := apiClient.CopyToContainer(ctx, cID, dstDir, preparedArchive, containertypes.CopyToContainerOptions{
173
+				CopyUIDGID: true,
174
+			})
175
+			assert.NilError(t, err)
176
+
177
+			res, err := container.Exec(ctx, apiClient, cID, []string{"stat", "-c", "%u:%g", "/empty-file.txt"})
178
+			assert.NilError(t, err)
179
+			assert.Equal(t, res.ExitCode, 0)
180
+			assert.Equal(t, strings.TrimSpace(res.Stdout()), tc.expected)
181
+		})
182
+	}
183
+}
184
+
185
+func makeTestImage(ctx context.Context, t *testing.T) (imageID string) {
186
+	t.Helper()
187
+	apiClient := testEnv.APIClient()
188
+	tmpDir := t.TempDir()
189
+	buildCtx := fakecontext.New(t, tmpDir, fakecontext.WithDockerfile(`
190
+		FROM busybox
191
+		RUN addgroup -g 2376 testgroup && adduser -D -u 2375 -G testgroup testuser
192
+		USER testuser:testgroup
193
+	`))
194
+	defer buildCtx.Close()
195
+
196
+	resp, err := apiClient.ImageBuild(ctx, buildCtx.AsTarReader(t), types.ImageBuildOptions{})
197
+	assert.NilError(t, err)
198
+	defer resp.Body.Close()
199
+
200
+	err = jsonmessage.DisplayJSONMessagesStream(resp.Body, io.Discard, 0, false, func(msg jsonmessage.JSONMessage) {
201
+		var r types.BuildResult
202
+		assert.NilError(t, json.Unmarshal(*msg.Aux, &r))
203
+		imageID = r.ID
204
+	})
205
+	assert.NilError(t, err)
206
+	assert.Assert(t, imageID != "")
207
+	return imageID
208
+}
209
+
92 210
 func makeEmptyArchive(t *testing.T) (string, io.ReadCloser) {
93 211
 	tmpDir := t.TempDir()
94 212
 	srcPath := filepath.Join(tmpDir, "empty-file.txt")