diff --git a/internal/apple/container.go b/internal/apple/container.go index bf5ed16..4eb0c39 100644 --- a/internal/apple/container.go +++ b/internal/apple/container.go @@ -1,12 +1,14 @@ package apple import ( + "bytes" "context" "fmt" "io" "os" "os/signal" "strconv" + "strings" "syscall" "time" @@ -36,6 +38,51 @@ type Container struct { readyBaseDelay time.Duration } +// InspectUser returns the uid and gid of the container's default user. +// It starts the container if not already running, then execs `id -u` and `id -g`. +func (c *Container) InspectUser(ctx context.Context) (runtime.ImageUser, error) { + if !c.started { + err := c.runner.Run(ctx, nil, os.Stdout, os.Stderr, + "container", "start", c.name, + ) + if err != nil { + return runtime.ImageUser{}, fmt.Errorf("failed to start container %q for user inspection: %w", c.name, err) + } + + if err := c.waitForRunning(ctx); err != nil { + return runtime.ImageUser{}, fmt.Errorf("container %q failed to become ready for user inspection: %w", c.name, err) + } + + c.started = true + } + + var uidBuf, gidBuf bytes.Buffer + + if err := c.runner.Run(ctx, nil, &uidBuf, os.Stderr, + "container", "exec", c.name, "id", "-u", + ); err != nil { + return runtime.ImageUser{}, fmt.Errorf("failed to get uid from container %q: %w", c.name, err) + } + + if err := c.runner.Run(ctx, nil, &gidBuf, os.Stderr, + "container", "exec", c.name, "id", "-g", + ); err != nil { + return runtime.ImageUser{}, fmt.Errorf("failed to get gid from container %q: %w", c.name, err) + } + + uid, err := strconv.Atoi(strings.TrimSpace(uidBuf.String())) + if err != nil { + return runtime.ImageUser{}, fmt.Errorf("unexpected output from id -u in container %q: %w", c.name, err) + } + + gid, err := strconv.Atoi(strings.TrimSpace(gidBuf.String())) + if err != nil { + return runtime.ImageUser{}, fmt.Errorf("unexpected output from id -g in container %q: %w", c.name, err) + } + + return runtime.ImageUser{UID: uid, GID: gid}, nil +} + // CopyTo starts the container and copies content via `container exec tar`. // Apple Container cannot copy files into a stopped container, so we start it first // with `sleep infinity`, then pipe the tar archive via exec. diff --git a/internal/docker/container.go b/internal/docker/container.go index 21176db..6691fe4 100644 --- a/internal/docker/container.go +++ b/internal/docker/container.go @@ -33,6 +33,23 @@ type Container struct { RetryDelay time.Duration } +// InspectUser returns the default user for the container's image by inspecting the container +// config. If the user is specified as a name rather than a numeric ID, it resolves the name +// via /etc/passwd and /etc/group copied from the stopped container. +func (c Container) InspectUser(ctx context.Context) (runtime.ImageUser, error) { + result, err := c.client.ContainerInspect(ctx, c.ID, client.ContainerInspectOptions{}) + if err != nil { + return runtime.ImageUser{}, fmt.Errorf("failed to inspect container %q: %w", c.Name, err) + } + + var userStr string + if result.Container.Config != nil { + userStr = result.Container.Config.User + } + + return resolveImageUser(ctx, c.client, c.ID, userStr) +} + // Start starts the container. Returns an error if the container fails to start, // which may indicate a misconfiguration or an unhealthy Docker daemon. func (c Container) Start(ctx context.Context) error { diff --git a/internal/docker/interface.go b/internal/docker/interface.go index 568fca0..f4e2ef3 100644 --- a/internal/docker/interface.go +++ b/internal/docker/interface.go @@ -31,7 +31,9 @@ import ( // c := docker.NewClient(&mockDockerClient{}) type DockerClient interface { ImageBuild(ctx context.Context, buildContext io.Reader, options client.ImageBuildOptions) (client.ImageBuildResult, error) + ContainerInspect(ctx context.Context, containerID string, options client.ContainerInspectOptions) (client.ContainerInspectResult, error) ContainerCreate(ctx context.Context, options client.ContainerCreateOptions) (client.ContainerCreateResult, error) + CopyFromContainer(ctx context.Context, containerID string, options client.CopyFromContainerOptions) (client.CopyFromContainerResult, error) ContainerStart(ctx context.Context, containerID string, options client.ContainerStartOptions) (client.ContainerStartResult, error) ContainerAttach(ctx context.Context, containerID string, options client.ContainerAttachOptions) (client.ContainerAttachResult, error) ContainerWait(ctx context.Context, containerID string, options client.ContainerWaitOptions) client.ContainerWaitResult diff --git a/internal/docker/mock_client_test.go b/internal/docker/mock_client_test.go index 6f9f394..e6de8b5 100644 --- a/internal/docker/mock_client_test.go +++ b/internal/docker/mock_client_test.go @@ -11,18 +11,20 @@ import ( // mockDockerClient is a mock implementation of docker.DockerClient for testing type mockDockerClient struct { - imageBuildFunc func(ctx context.Context, buildContext io.Reader, options client.ImageBuildOptions) (client.ImageBuildResult, error) - containerCreateFunc func(ctx context.Context, options client.ContainerCreateOptions) (client.ContainerCreateResult, error) - containerStartFunc func(ctx context.Context, containerID string, options client.ContainerStartOptions) (client.ContainerStartResult, error) - containerAttachFunc func(ctx context.Context, containerID string, options client.ContainerAttachOptions) (client.ContainerAttachResult, error) - containerWaitFunc func(ctx context.Context, containerID string, options client.ContainerWaitOptions) client.ContainerWaitResult - containerStopFunc func(ctx context.Context, containerID string, options client.ContainerStopOptions) (client.ContainerStopResult, error) - containerRemoveFunc func(ctx context.Context, containerID string, options client.ContainerRemoveOptions) (client.ContainerRemoveResult, error) - containerResizeFunc func(ctx context.Context, containerID string, options client.ContainerResizeOptions) (client.ContainerResizeResult, error) - copyToContainerFunc func(ctx context.Context, containerID string, options client.CopyToContainerOptions) (client.CopyToContainerResult, error) - pingFunc func(ctx context.Context, options client.PingOptions) (client.PingResult, error) - containerListFunc func(ctx context.Context, options client.ContainerListOptions) (client.ContainerListResult, error) - closeFunc func() error + imageBuildFunc func(ctx context.Context, buildContext io.Reader, options client.ImageBuildOptions) (client.ImageBuildResult, error) + containerInspectFunc func(ctx context.Context, containerID string, options client.ContainerInspectOptions) (client.ContainerInspectResult, error) + containerCreateFunc func(ctx context.Context, options client.ContainerCreateOptions) (client.ContainerCreateResult, error) + copyFromContainerFunc func(ctx context.Context, containerID string, options client.CopyFromContainerOptions) (client.CopyFromContainerResult, error) + containerStartFunc func(ctx context.Context, containerID string, options client.ContainerStartOptions) (client.ContainerStartResult, error) + containerAttachFunc func(ctx context.Context, containerID string, options client.ContainerAttachOptions) (client.ContainerAttachResult, error) + containerWaitFunc func(ctx context.Context, containerID string, options client.ContainerWaitOptions) client.ContainerWaitResult + containerStopFunc func(ctx context.Context, containerID string, options client.ContainerStopOptions) (client.ContainerStopResult, error) + containerRemoveFunc func(ctx context.Context, containerID string, options client.ContainerRemoveOptions) (client.ContainerRemoveResult, error) + containerResizeFunc func(ctx context.Context, containerID string, options client.ContainerResizeOptions) (client.ContainerResizeResult, error) + copyToContainerFunc func(ctx context.Context, containerID string, options client.CopyToContainerOptions) (client.CopyToContainerResult, error) + pingFunc func(ctx context.Context, options client.PingOptions) (client.PingResult, error) + containerListFunc func(ctx context.Context, options client.ContainerListOptions) (client.ContainerListResult, error) + closeFunc func() error } func (m *mockDockerClient) ImageBuild(ctx context.Context, buildContext io.Reader, options client.ImageBuildOptions) (client.ImageBuildResult, error) { @@ -32,6 +34,13 @@ func (m *mockDockerClient) ImageBuild(ctx context.Context, buildContext io.Reade return client.ImageBuildResult{}, errors.New("not implemented") } +func (m *mockDockerClient) ContainerInspect(ctx context.Context, containerID string, options client.ContainerInspectOptions) (client.ContainerInspectResult, error) { + if m.containerInspectFunc != nil { + return m.containerInspectFunc(ctx, containerID, options) + } + return client.ContainerInspectResult{}, errors.New("not implemented") +} + func (m *mockDockerClient) ContainerCreate(ctx context.Context, options client.ContainerCreateOptions) (client.ContainerCreateResult, error) { if m.containerCreateFunc != nil { return m.containerCreateFunc(ctx, options) @@ -91,6 +100,13 @@ func (m *mockDockerClient) CopyToContainer(ctx context.Context, containerID stri return client.CopyToContainerResult{}, errors.New("not implemented") } +func (m *mockDockerClient) CopyFromContainer(ctx context.Context, containerID string, options client.CopyFromContainerOptions) (client.CopyFromContainerResult, error) { + if m.copyFromContainerFunc != nil { + return m.copyFromContainerFunc(ctx, containerID, options) + } + return client.CopyFromContainerResult{}, errors.New("not implemented") +} + func (m *mockDockerClient) Ping(ctx context.Context, options client.PingOptions) (client.PingResult, error) { if m.pingFunc != nil { return m.pingFunc(ctx, options) diff --git a/internal/docker/user.go b/internal/docker/user.go new file mode 100644 index 0000000..9d9b037 --- /dev/null +++ b/internal/docker/user.go @@ -0,0 +1,151 @@ +package docker + +import ( + "archive/tar" + "bufio" + "context" + "fmt" + "io" + "strconv" + "strings" + + "github.com/moby/moby/client" + "github.com/ryanmoran/contagent/internal/runtime" +) + +// resolveImageUser parses the Docker USER field and resolves it to uid/gid. +// The USER field format is "[user][:group]" where each part can be a name or numeric ID. +// If names are present, they are resolved via /etc/passwd and /etc/group copied from +// the container with the given containerID (which may be stopped). +func resolveImageUser(ctx context.Context, dockerClient DockerClient, containerID, userStr string) (runtime.ImageUser, error) { + if userStr == "" { + return runtime.ImageUser{UID: 0, GID: 0}, nil + } + + userPart, groupPart, hasGroup := strings.Cut(userStr, ":") + + userUID, userIsNumeric := tryParseInt(userPart) + groupGID, groupIsNumeric := tryParseInt(groupPart) + + // Fast path: both parts are numeric (or group is absent with a numeric user) + if userIsNumeric && (!hasGroup || groupIsNumeric) { + gid := userUID // default gid = uid when no group specified + if hasGroup { + gid = groupGID + } + return runtime.ImageUser{UID: userUID, GID: gid}, nil + } + + // Slow path: resolve names via /etc/passwd and /etc/group from the container + var uid, gid int + var err error + + if userIsNumeric { + uid = userUID + gid = userUID // fallback; may be overridden below + } else { + uid, gid, err = lookupUser(ctx, dockerClient, containerID, userPart) + if err != nil { + return runtime.ImageUser{}, fmt.Errorf("failed to resolve user %q: %w", userPart, err) + } + } + + if hasGroup { + if groupIsNumeric { + gid = groupGID + } else { + gid, err = lookupGroup(ctx, dockerClient, containerID, groupPart) + if err != nil { + return runtime.ImageUser{}, fmt.Errorf("failed to resolve group %q: %w", groupPart, err) + } + } + } + + return runtime.ImageUser{UID: uid, GID: gid}, nil +} + +func tryParseInt(s string) (int, bool) { + if s == "" { + return 0, false + } + n, err := strconv.Atoi(s) + return n, err == nil +} + +func copyFileFromContainer(ctx context.Context, dockerClient DockerClient, containerID, srcPath string) (string, error) { + result, err := dockerClient.CopyFromContainer(ctx, containerID, client.CopyFromContainerOptions{ + SourcePath: srcPath, + }) + if err != nil { + return "", fmt.Errorf("failed to copy %q from container: %w", srcPath, err) + } + defer result.Content.Close() + + tr := tar.NewReader(result.Content) + if _, err = tr.Next(); err != nil { + return "", fmt.Errorf("failed to read tar entry from container copy: %w", err) + } + + content, err := io.ReadAll(tr) + if err != nil { + return "", fmt.Errorf("failed to read file content: %w", err) + } + + return string(content), nil +} + +// lookupUser finds a username in /etc/passwd and returns its uid and primary gid. +func lookupUser(ctx context.Context, dockerClient DockerClient, containerID, username string) (uid, gid int, err error) { + content, err := copyFileFromContainer(ctx, dockerClient, containerID, "/etc/passwd") + if err != nil { + return 0, 0, err + } + + scanner := bufio.NewScanner(strings.NewReader(content)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + fields := strings.SplitN(line, ":", 7) + if len(fields) < 4 || fields[0] != username { + continue + } + uid, err = strconv.Atoi(fields[2]) + if err != nil { + return 0, 0, fmt.Errorf("invalid uid for user %q in /etc/passwd: %w", username, err) + } + gid, err = strconv.Atoi(fields[3]) + if err != nil { + return 0, 0, fmt.Errorf("invalid gid for user %q in /etc/passwd: %w", username, err) + } + return uid, gid, nil + } + return 0, 0, fmt.Errorf("user %q not found in /etc/passwd", username) +} + +// lookupGroup finds a group name in /etc/group and returns its gid. +func lookupGroup(ctx context.Context, dockerClient DockerClient, containerID, groupName string) (gid int, err error) { + content, err := copyFileFromContainer(ctx, dockerClient, containerID, "/etc/group") + if err != nil { + return 0, err + } + + scanner := bufio.NewScanner(strings.NewReader(content)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + fields := strings.SplitN(line, ":", 4) + if len(fields) < 3 || fields[0] != groupName { + continue + } + gid, err = strconv.Atoi(fields[2]) + if err != nil { + return 0, fmt.Errorf("invalid gid for group %q in /etc/group: %w", groupName, err) + } + return gid, nil + } + return 0, fmt.Errorf("group %q not found in /etc/group", groupName) +} diff --git a/internal/git/archive.go b/internal/git/archive.go index 9fe04d9..1bcba45 100644 --- a/internal/git/archive.go +++ b/internal/git/archive.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strings" "github.com/ryanmoran/contagent/internal" @@ -19,10 +20,18 @@ import ( // configures the remote, creates a new branch, and archives the .git directory and all tracked // files. The git user name and email are configured in the temporary repository. // +// uid and gid are applied to all tar headers so that extracted files are owned by the correct +// container user. +// +// When destDir is non-empty, all archive paths are prefixed with destDir and a root directory +// entry is written first. This allows copying to the parent directory so Docker creates destDir +// as a new entry with the correct uid/gid ownership, rather than copying into an already-existing +// root-owned directory. +// // Returns an io.ReadCloser that streams the tar archive. The caller must close it to clean up // resources. Returns an error if the Git root cannot be determined, the temporary directory cannot // be created, .git copying fails, git operations fail, or archive creation fails. -func CreateArchive(path, remote, branch, gitUserName, gitUserEmail string, w internal.Writer) (io.ReadCloser, error) { +func CreateArchive(path, remote, branch, gitUserName, gitUserEmail string, uid, gid int, destDir string, w internal.Writer) (io.ReadCloser, error) { cmd := exec.Command("git", "rev-parse", "--show-toplevel") cmd.Dir = path output, err := cmd.Output() @@ -100,7 +109,27 @@ func CreateArchive(path, remote, branch, gitUserName, gitUserEmail string, w int return fmt.Errorf("failed to create and checkout branch %q: %w\nBranch may already exist", branch, err) } - if err := addDirectoryToArchive(tw, dst, ".git"); err != nil { + prefix := func(name string) string { + if destDir == "" { + return name + } + return destDir + "/" + name + } + + if destDir != "" { + rootHeader := &tar.Header{ + Name: destDir + "/", + Mode: 0755, + Typeflag: tar.TypeDir, + Uid: uid, + Gid: gid, + } + if err := tw.WriteHeader(rootHeader); err != nil { + return fmt.Errorf("failed to write root directory header: %w", err) + } + } + + if err := addDirectoryToArchive(tw, dst, prefix(".git"), uid, gid); err != nil { return fmt.Errorf("failed to add .git directory: %w", err) } @@ -111,13 +140,61 @@ func CreateArchive(path, remote, branch, gitUserName, gitUserEmail string, w int return fmt.Errorf("failed to list git tracked files: %w\nRepository may be corrupted", err) } + // Collect file paths and all unique parent directories + var filePaths []string + dirsSeen := make(map[string]bool) + var sortedDirs []string + scanner := bufio.NewScanner(strings.NewReader(string(output))) for scanner.Scan() { relPath := scanner.Text() if relPath == "" { continue } + filePaths = append(filePaths, relPath) + + // Collect all parent directories of this file + dir := filepath.Dir(relPath) + for dir != "." && dir != "/" { + dirPath := strings.ReplaceAll(dir, "\\", "/") + if !dirsSeen[dirPath] { + dirsSeen[dirPath] = true + sortedDirs = append(sortedDirs, dirPath) + } + dir = filepath.Dir(dir) + } + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("error reading git file list: %w", err) + } + + // Sort so parent directories come before their children + sort.Strings(sortedDirs) + + // Write directory headers first so they have proper permissions when extracted + for _, dirPath := range sortedDirs { + fullPath := filepath.Join(tempRoot, dirPath) + info, err := os.Lstat(fullPath) //nolint:gosec // path is constructed from a controlled temp root + if err != nil { + continue + } + header := &tar.Header{ + Name: prefix(dirPath) + "/", + Mode: int64(info.Mode()), + ModTime: info.ModTime(), + Typeflag: tar.TypeDir, + Uid: uid, + Gid: gid, + } + if err := tw.WriteHeader(header); err != nil { + return fmt.Errorf("failed to write directory header for %s: %w", dirPath, err) + } + } + + // Write tracked files + for _, relPath := range filePaths { fullPath := filepath.Join(tempRoot, relPath) info, err := os.Lstat(fullPath) //nolint:gosec // path is constructed from a controlled temp root if err != nil { @@ -129,41 +206,31 @@ func CreateArchive(path, remote, branch, gitUserName, gitUserEmail string, w int } if info.IsDir() { - header := &tar.Header{ - Name: relPath + "/", - Mode: int64(info.Mode()), - ModTime: info.ModTime(), - Typeflag: tar.TypeDir, - } - if err := tw.WriteHeader(header); err != nil { - return fmt.Errorf("failed to write directory header for %s: %w", relPath, err) - } - } else { - file, err := os.Open(fullPath) //nolint:gosec // path is constructed from a controlled temp root - if err != nil { - return fmt.Errorf("failed to open tracked file %q: %w\nFile may have been deleted", relPath, err) - } - defer file.Close() + continue + } - header := &tar.Header{ - Name: relPath, - Mode: int64(info.Mode()), - Size: info.Size(), - ModTime: info.ModTime(), - } + file, err := os.Open(fullPath) //nolint:gosec // path is constructed from a controlled temp root + if err != nil { + return fmt.Errorf("failed to open tracked file %q: %w\nFile may have been deleted", relPath, err) + } + defer file.Close() - if err := tw.WriteHeader(header); err != nil { - return fmt.Errorf("failed to write header for %s: %w", relPath, err) - } + header := &tar.Header{ + Name: prefix(relPath), + Mode: int64(info.Mode()), + Size: info.Size(), + ModTime: info.ModTime(), + Uid: uid, + Gid: gid, + } - if _, err := io.Copy(tw, file); err != nil { - return fmt.Errorf("failed to write file %s: %w", relPath, err) - } + if err := tw.WriteHeader(header); err != nil { + return fmt.Errorf("failed to write header for %s: %w", relPath, err) } - } - if err := scanner.Err(); err != nil { - return fmt.Errorf("error reading git file list: %w", err) + if _, err := io.Copy(tw, file); err != nil { + return fmt.Errorf("failed to write file %s: %w", relPath, err) + } } return nil @@ -201,7 +268,7 @@ func (a *archiveCloser) Close() error { return a.pr.Close() } -func addDirectoryToArchive(tw *tar.Writer, srcDir, tarPath string) error { +func addDirectoryToArchive(tw *tar.Writer, srcDir, tarPath string, uid, gid int) error { return filepath.Walk(srcDir, func(filePath string, info os.FileInfo, err error) error { if err != nil { return err @@ -225,6 +292,8 @@ func addDirectoryToArchive(tw *tar.Writer, srcDir, tarPath string) error { Mode: int64(info.Mode()), ModTime: info.ModTime(), Typeflag: tar.TypeDir, + Uid: uid, + Gid: gid, } return tw.WriteHeader(header) } else { @@ -239,6 +308,8 @@ func addDirectoryToArchive(tw *tar.Writer, srcDir, tarPath string) error { Mode: int64(info.Mode()), Size: info.Size(), ModTime: info.ModTime(), + Uid: uid, + Gid: gid, } if err := tw.WriteHeader(header); err != nil { diff --git a/internal/git/archive_test.go b/internal/git/archive_test.go index af6ed0a..d5b85ab 100644 --- a/internal/git/archive_test.go +++ b/internal/git/archive_test.go @@ -57,7 +57,7 @@ func TestCreateArchive(t *testing.T) { userName := "Archive User" userEmail := "archive@example.com" - reader, err := git.CreateArchive(dir, remote, branch, userName, userEmail, internal.NewStandardWriter()) + reader, err := git.CreateArchive(dir, remote, branch, userName, userEmail, 0, 0, "", internal.NewStandardWriter()) require.NoError(t, err) defer reader.Close() @@ -95,8 +95,7 @@ func TestCreateArchive(t *testing.T) { // Verify expected files are in archive require.True(t, files[".git/"], "should contain .git/ directory") require.True(t, files["test.txt"], "should contain test.txt") - // Note: subdirectories are not explicitly added unless they contain files - // tar will create them automatically when extracting files + require.True(t, files["subdir/"], "should contain subdir/ directory header") require.True(t, files["subdir/nested.txt"], "should contain subdir/nested.txt") // Verify file contents @@ -151,7 +150,7 @@ func TestCreateArchive(t *testing.T) { require.NoError(t, err) defer os.RemoveAll(dir) - _, err = git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", internal.NewStandardWriter()) + _, err = git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", 0, 0, "", internal.NewStandardWriter()) require.ErrorContains(t, err, "failed to get git root path") }) @@ -185,7 +184,7 @@ func TestCreateArchive(t *testing.T) { // Create archive should succeed remote := "http://example.com/repo.git" - reader, err := git.CreateArchive(dir, remote, "branch", "user", "user@example.com", internal.NewStandardWriter()) + reader, err := git.CreateArchive(dir, remote, "branch", "user", "user@example.com", 0, 0, "", internal.NewStandardWriter()) require.NoError(t, err) defer reader.Close() @@ -228,7 +227,7 @@ func TestCreateArchive(t *testing.T) { require.NoError(t, cmd.Run()) // Create archive - reader, err := git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", internal.NewStandardWriter()) + reader, err := git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", 0, 0, "", internal.NewStandardWriter()) require.NoError(t, err) defer reader.Close() @@ -282,7 +281,7 @@ func TestCreateArchive(t *testing.T) { require.NoError(t, cmd.Run()) // Create archive from subdirectory - reader, err := git.CreateArchive(subDir, "http://example.com", "branch", "user", "user@example.com", internal.NewStandardWriter()) + reader, err := git.CreateArchive(subDir, "http://example.com", "branch", "user", "user@example.com", 0, 0, "", internal.NewStandardWriter()) require.NoError(t, err) defer reader.Close() @@ -313,7 +312,7 @@ func TestCreateArchive(t *testing.T) { require.NoError(t, cmd.Run()) // Create archive from empty repo - returns reader but will error when reading - reader, err := git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", internal.NewStandardWriter()) + reader, err := git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", 0, 0, "", internal.NewStandardWriter()) // CreateArchive returns immediately with a reader, error happens in goroutine require.NoError(t, err) if reader != nil { @@ -374,7 +373,7 @@ func TestCopyDirectory(t *testing.T) { require.NoError(t, cmd.Run()) // Create archive (this will internally use copyDirectory) - reader, err := git.CreateArchive(gitDir, "http://example.com", "branch", "user", "user@example.com", internal.NewStandardWriter()) + reader, err := git.CreateArchive(gitDir, "http://example.com", "branch", "user", "user@example.com", 0, 0, "", internal.NewStandardWriter()) require.NoError(t, err) defer reader.Close() @@ -448,7 +447,7 @@ func TestCopyDirectory(t *testing.T) { require.NoError(t, cmd.Run()) // Create archive - reader, err := git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", internal.NewStandardWriter()) + reader, err := git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", 0, 0, "", internal.NewStandardWriter()) require.NoError(t, err) defer reader.Close() @@ -501,7 +500,7 @@ func TestCopyDirectory(t *testing.T) { require.NoError(t, cmd.Run()) // Create archive - reader, err := git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", internal.NewStandardWriter()) + reader, err := git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", 0, 0, "", internal.NewStandardWriter()) require.NoError(t, err) defer reader.Close() @@ -571,7 +570,7 @@ func TestCopyFile(t *testing.T) { ) require.NoError(t, cmd.Run()) - reader, err := git.CreateArchive(gitDir, "http://example.com", "branch", "user", "user@example.com", internal.NewStandardWriter()) + reader, err := git.CreateArchive(gitDir, "http://example.com", "branch", "user", "user@example.com", 0, 0, "", internal.NewStandardWriter()) require.NoError(t, err) defer reader.Close() @@ -636,7 +635,7 @@ func TestCopyFile(t *testing.T) { ) require.NoError(t, cmd.Run()) - reader, err := git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", internal.NewStandardWriter()) + reader, err := git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", 0, 0, "", internal.NewStandardWriter()) require.NoError(t, err) defer reader.Close() @@ -658,6 +657,196 @@ func TestCopyFile(t *testing.T) { }) } +func TestArchiveOwnership(t *testing.T) { + t.Run("all tar headers have uid and gid set to 1001", func(t *testing.T) { + dir, err := os.MkdirTemp("", "git-ownership-test") + require.NoError(t, err) + defer os.RemoveAll(dir) + + cmd := exec.Command("git", "init") + cmd.Dir = dir + require.NoError(t, cmd.Run()) + + // Create files and subdirectories + subDir := filepath.Join(dir, "subdir") + require.NoError(t, os.MkdirAll(subDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "root.txt"), []byte("root\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(subDir, "nested.txt"), []byte("nested\n"), 0600)) + + cmd = exec.Command("git", "add", ".") + cmd.Dir = dir + require.NoError(t, cmd.Run()) + + cmd = exec.Command("git", "commit", "-m", "commit") + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=Test User", + "GIT_AUTHOR_EMAIL=test@example.com", + "GIT_COMMITTER_NAME=Test User", + "GIT_COMMITTER_EMAIL=test@example.com", + ) + require.NoError(t, cmd.Run()) + + reader, err := git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", 1001, 1001, "", internal.NewStandardWriter()) + require.NoError(t, err) + defer reader.Close() + + tr := tar.NewReader(reader) + count := 0 + for { + header, err := tr.Next() + if err == io.EOF { + break + } + require.NoError(t, err) + count++ + require.Equal(t, 1001, header.Uid, "header %q should have Uid 1001", header.Name) + require.Equal(t, 1001, header.Gid, "header %q should have Gid 1001", header.Name) + } + require.Greater(t, count, 0, "archive should contain at least one entry") + }) + + t.Run("tracked file headers have uid and gid 1001", func(t *testing.T) { + dir, err := os.MkdirTemp("", "git-file-ownership-test") + require.NoError(t, err) + defer os.RemoveAll(dir) + + cmd := exec.Command("git", "init") + cmd.Dir = dir + require.NoError(t, cmd.Run()) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "file.txt"), []byte("content\n"), 0600)) + + cmd = exec.Command("git", "add", ".") + cmd.Dir = dir + require.NoError(t, cmd.Run()) + + cmd = exec.Command("git", "commit", "-m", "commit") + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=Test User", + "GIT_AUTHOR_EMAIL=test@example.com", + "GIT_COMMITTER_NAME=Test User", + "GIT_COMMITTER_EMAIL=test@example.com", + ) + require.NoError(t, cmd.Run()) + + reader, err := git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", 1001, 1001, "", internal.NewStandardWriter()) + require.NoError(t, err) + defer reader.Close() + + tr := tar.NewReader(reader) + found := false + for { + header, err := tr.Next() + if err == io.EOF { + break + } + require.NoError(t, err) + if header.Name == "file.txt" { + found = true + require.Equal(t, 1001, header.Uid, "tracked file should have Uid 1001") + require.Equal(t, 1001, header.Gid, "tracked file should have Gid 1001") + } + } + require.True(t, found, "file.txt should be in the archive") + }) + + t.Run("tracked directory headers have uid and gid 1001", func(t *testing.T) { + dir, err := os.MkdirTemp("", "git-dir-ownership-test") + require.NoError(t, err) + defer os.RemoveAll(dir) + + cmd := exec.Command("git", "init") + cmd.Dir = dir + require.NoError(t, cmd.Run()) + + subDir := filepath.Join(dir, "mydir") + require.NoError(t, os.MkdirAll(subDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(subDir, "file.txt"), []byte("content\n"), 0600)) + + cmd = exec.Command("git", "add", ".") + cmd.Dir = dir + require.NoError(t, cmd.Run()) + + cmd = exec.Command("git", "commit", "-m", "commit") + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=Test User", + "GIT_AUTHOR_EMAIL=test@example.com", + "GIT_COMMITTER_NAME=Test User", + "GIT_COMMITTER_EMAIL=test@example.com", + ) + require.NoError(t, cmd.Run()) + + reader, err := git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", 1001, 1001, "", internal.NewStandardWriter()) + require.NoError(t, err) + defer reader.Close() + + tr := tar.NewReader(reader) + found := false + for { + header, err := tr.Next() + if err == io.EOF { + break + } + require.NoError(t, err) + if header.Name == "mydir/" { + found = true + require.Equal(t, 1001, header.Uid, "tracked directory should have Uid 1001") + require.Equal(t, 1001, header.Gid, "tracked directory should have Gid 1001") + } + } + require.True(t, found, "mydir/ directory header should be in the archive") + }) + + t.Run(".git directory entries have uid and gid 1001", func(t *testing.T) { + dir, err := os.MkdirTemp("", "git-dot-git-ownership-test") + require.NoError(t, err) + defer os.RemoveAll(dir) + + cmd := exec.Command("git", "init") + cmd.Dir = dir + require.NoError(t, cmd.Run()) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "file.txt"), []byte("content\n"), 0600)) + + cmd = exec.Command("git", "add", ".") + cmd.Dir = dir + require.NoError(t, cmd.Run()) + + cmd = exec.Command("git", "commit", "-m", "commit") + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=Test User", + "GIT_AUTHOR_EMAIL=test@example.com", + "GIT_COMMITTER_NAME=Test User", + "GIT_COMMITTER_EMAIL=test@example.com", + ) + require.NoError(t, cmd.Run()) + + reader, err := git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", 1001, 1001, "", internal.NewStandardWriter()) + require.NoError(t, err) + defer reader.Close() + + tr := tar.NewReader(reader) + gitEntries := 0 + for { + header, err := tr.Next() + if err == io.EOF { + break + } + require.NoError(t, err) + if strings.HasPrefix(header.Name, ".git/") { + gitEntries++ + require.Equal(t, 1001, header.Uid, ".git entry %q should have Uid 1001", header.Name) + require.Equal(t, 1001, header.Gid, ".git entry %q should have Gid 1001", header.Name) + } + } + require.Greater(t, gitEntries, 0, "archive should contain .git entries") + }) +} + func TestAddDirectoryToArchive(t *testing.T) { t.Run("adds .git directory to archive", func(t *testing.T) { // This function is tested indirectly through CreateArchive @@ -687,7 +876,7 @@ func TestAddDirectoryToArchive(t *testing.T) { ) require.NoError(t, cmd.Run()) - reader, err := git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", internal.NewStandardWriter()) + reader, err := git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", 0, 0, "", internal.NewStandardWriter()) require.NoError(t, err) defer reader.Close() @@ -737,7 +926,7 @@ func TestAddDirectoryToArchive(t *testing.T) { ) require.NoError(t, cmd.Run()) - reader, err := git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", internal.NewStandardWriter()) + reader, err := git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", 0, 0, "", internal.NewStandardWriter()) require.NoError(t, err) defer reader.Close() diff --git a/internal/git/errors_test.go b/internal/git/errors_test.go index 5415b1e..08ba756 100644 --- a/internal/git/errors_test.go +++ b/internal/git/errors_test.go @@ -94,13 +94,13 @@ func TestGitArchiveErrorCases(t *testing.T) { require.NoError(t, err) defer os.RemoveAll(dir) - _, err = git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", internal.NewStandardWriter()) + _, err = git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", 0, 0, "", internal.NewStandardWriter()) require.Error(t, err) require.Contains(t, err.Error(), "failed to get git root path") }) t.Run("non-existent directory", func(t *testing.T) { - _, err := git.CreateArchive("/nonexistent/path", "http://example.com", "branch", "user", "user@example.com", internal.NewStandardWriter()) + _, err := git.CreateArchive("/nonexistent/path", "http://example.com", "branch", "user", "user@example.com", 0, 0, "", internal.NewStandardWriter()) require.Error(t, err) require.Contains(t, err.Error(), "failed to get git root path") }) @@ -115,7 +115,7 @@ func TestGitArchiveErrorCases(t *testing.T) { cmd.Dir = dir require.NoError(t, cmd.Run()) - reader, err := git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", internal.NewStandardWriter()) + reader, err := git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", 0, 0, "", internal.NewStandardWriter()) require.NoError(t, err) // Returns immediately if reader != nil { defer reader.Close() @@ -169,7 +169,7 @@ func TestGitArchiveErrorCases(t *testing.T) { } }) - _, err = git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", internal.NewStandardWriter()) + _, err = git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", 0, 0, "", internal.NewStandardWriter()) require.Error(t, err) }) @@ -203,7 +203,7 @@ func TestGitArchiveErrorCases(t *testing.T) { // Git accepts most URLs, but we test that it doesn't fail during archive creation // The URL validation happens when actually using the remote - reader, err := git.CreateArchive(dir, "not-a-valid-url", "branch", "user", "user@example.com", internal.NewStandardWriter()) + reader, err := git.CreateArchive(dir, "not-a-valid-url", "branch", "user", "user@example.com", 0, 0, "", internal.NewStandardWriter()) require.NoError(t, err) // Archive creation succeeds even with invalid URL if reader != nil { reader.Close() @@ -239,7 +239,7 @@ func TestGitArchiveErrorCases(t *testing.T) { require.NoError(t, cmd.Run()) // Try to create archive with invalid branch name (contains spaces) - reader, err := git.CreateArchive(dir, "http://example.com", "invalid branch name", "user", "user@example.com", internal.NewStandardWriter()) + reader, err := git.CreateArchive(dir, "http://example.com", "invalid branch name", "user", "user@example.com", 0, 0, "", internal.NewStandardWriter()) if err == nil && reader != nil { defer reader.Close() // Error may occur when reading @@ -282,7 +282,7 @@ func TestGitArchiveErrorCases(t *testing.T) { require.NoError(t, cmd.Run()) // Empty user name and email are technically valid in git - reader, err := git.CreateArchive(dir, "http://example.com", "branch", "", "", internal.NewStandardWriter()) + reader, err := git.CreateArchive(dir, "http://example.com", "branch", "", "", 0, 0, "", internal.NewStandardWriter()) require.NoError(t, err) if reader != nil { reader.Close() @@ -323,7 +323,7 @@ func TestGitArchiveErrorCases(t *testing.T) { require.NoError(t, cmd.Run()) // Archive will fail because it tries to create a branch that already exists in the copied .git - reader, err := git.CreateArchive(dir, "http://example.com", "test-branch", "user", "user@example.com", internal.NewStandardWriter()) + reader, err := git.CreateArchive(dir, "http://example.com", "test-branch", "user", "user@example.com", 0, 0, "", internal.NewStandardWriter()) if err == nil && reader != nil { defer reader.Close() // Error may occur during read @@ -372,7 +372,7 @@ func TestGitArchiveErrorCases(t *testing.T) { require.NoError(t, cmd.Run()) // Archive should still work with detached HEAD - reader, err := git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", internal.NewStandardWriter()) + reader, err := git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", 0, 0, "", internal.NewStandardWriter()) require.NoError(t, err) if reader != nil { defer reader.Close() @@ -413,7 +413,7 @@ func TestGitArchiveErrorCases(t *testing.T) { require.NoError(t, os.WriteFile(testFile, []byte("uncommitted\n"), 0600)) // Archive should only include committed content (archives HEAD, not working tree) - reader, err := git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", internal.NewStandardWriter()) + reader, err := git.CreateArchive(dir, "http://example.com", "branch", "user", "user@example.com", 0, 0, "", internal.NewStandardWriter()) require.NoError(t, err) require.NotNil(t, reader) reader.Close() @@ -447,7 +447,7 @@ func TestGitArchiveErrorCases(t *testing.T) { require.NoError(t, cmd.Run()) // Archive should handle repos with .gitmodules (even if submodules not initialized) - reader, err := git.CreateArchive(mainDir, "http://example.com", "branch", "user", "user@example.com", internal.NewStandardWriter()) + reader, err := git.CreateArchive(mainDir, "http://example.com", "branch", "user", "user@example.com", 0, 0, "", internal.NewStandardWriter()) require.NoError(t, err) if reader != nil { reader.Close() diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index e70224d..8605918 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -13,6 +13,12 @@ type Image struct { Name string } +// ImageUser represents the default user for a container image. +type ImageUser struct { + UID int + GID int +} + // CreateContainerOptions bundles the configuration for creating a container. type CreateContainerOptions struct { SessionID internal.SessionID @@ -37,6 +43,7 @@ type Runtime interface { // Container is the interface for interacting with a container. type Container interface { + InspectUser(ctx context.Context) (ImageUser, error) CopyTo(ctx context.Context, content io.Reader, path string) error Start(ctx context.Context) error Attach(ctx context.Context, w internal.Writer) error diff --git a/main.go b/main.go index 64f03e0..1e053c7 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "log" "os" "os/signal" + "path/filepath" "syscall" "github.com/ryanmoran/contagent/internal" @@ -119,12 +120,20 @@ func run(args, env []string) error { return container.ForceRemove(ctx) }) + imageUser, err := container.InspectUser(ctx) + if err != nil { + return fmt.Errorf("failed to inspect user for image %q: %w", image.Name, err) + } + archive, err := git.CreateArchive( workingDirectory, fmt.Sprintf("http://%s:%d", rt.HostAddress(), remote.Port()), session.Branch(), config.GitUser.Name, config.GitUser.Email, + imageUser.UID, + imageUser.GID, + filepath.Base(config.WorkingDir), w, ) if err != nil { @@ -132,7 +141,7 @@ func run(args, env []string) error { } cleanup.Add("archive", archive.Close) - err = container.CopyTo(ctx, archive, config.WorkingDir) + err = container.CopyTo(ctx, archive, filepath.Dir(config.WorkingDir)) if err != nil { return fmt.Errorf("failed to copy git archive to container %q: %w", session.ID(), err) }