diff --git a/.gitignore b/.gitignore index a720ac53..a4dbc3ee 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,4 @@ unit-test-coverage-report.html .DS_Store *.test build -THIRD_PARTY_LICENSES +THIRD_PARTY_LICENSES \ No newline at end of file diff --git a/Makefile b/Makefile index e81e4146..3b594450 100644 --- a/Makefile +++ b/Makefile @@ -17,17 +17,44 @@ ifndef GODEBUG EXTRA_LDFLAGS += -s -w endif -.PHONY: build -build: +NERDCTL_REPO = https://github.com/containerd/nerdctl.git +NERDCTL_TAG = v1.7.7 +NERDCTL_USERNS_PATCH = patches/userns.patch +NERDCTL_NONE_NETWORK_PATCH = patches/none_network.patch + +.PHONY: patch-nerdctl build clean restore-mod +build: patch-nerdctl $(eval PACKAGE := github.com/runfinch/finch-daemon) $(eval VERSION ?= $(shell git describe --match 'v[0-9]*' --dirty='.modified' --always --tags)) $(eval GITCOMMIT := $(shell git rev-parse HEAD)$(shell if ! git diff --no-ext-diff --quiet --exit-code; then echo .m; fi)) $(eval LDFLAGS := "-X $(PACKAGE)/version.Version=$(VERSION) -X $(PACKAGE)/version.GitCommit=$(GITCOMMIT) $(EXTRA_LDFLAGS)") + go mod edit -replace=github.com/containerd/nerdctl@v1.7.7=./build/nerdctl && go mod tidy GOOS=linux go build -ldflags $(LDFLAGS) -v -o $(BINARY) $(PACKAGE)/cmd/finch-daemon + $(MAKE) restore-mod + +patch-nerdctl: + rm -rf build && mkdir -p build + + cp go.mod build/go.mod.bak + cp go.sum build/go.sum.bak + + cd build && git clone $(NERDCTL_REPO) + cd build/nerdctl && git fetch --tags + cd build/nerdctl && git checkout tags/$(NERDCTL_TAG) -b $(NERDCTL_TAG)-branch + cd build/nerdctl && git apply ../../$(NERDCTL_USERNS_PATCH) + cd build/nerdctl && git apply ../../$(NERDCTL_NONE_NETWORK_PATCH) + +restore-mod: + mv build/go.mod.bak go.mod + mv build/go.sum.bak go.sum + +clean-build-dir: + rm -rf build clean: @rm -f $(BINARIES) @rm -rf $(BIN) + @rm -rf build .PHONY: linux linux: @@ -86,7 +113,10 @@ lint: linux $(GOLINT) .PHONY: test-unit test-unit: linux - $(GINKGO) $(GFLAGS) ./... + $(MAKE) patch-nerdctl + go mod edit -replace=github.com/containerd/nerdctl@v1.7.7=./build/nerdctl && go mod tidy + $(GINKGO) $(GFLAGS) --skip-package nerdctl ./... + $(MAKE) restore-mod # Runs tests in headless dlv mode, must specify package directory with PKG_DIR PKG_DIR ?= . @@ -99,7 +129,10 @@ test-e2e: linux DOCKER_HOST="unix:///run/finch.sock" \ DOCKER_API_VERSION="v1.41" \ TEST_E2E=1 \ + $(MAKE) patch-nerdctl + go mod edit -replace=github.com/containerd/nerdctl@v1.7.7=./build/nerdctl && go mod tidy $(GINKGO) $(GFLAGS) ./e2e/... + $(MAKE) restore-mod .PHONY: licenses licenses: diff --git a/patches/none_network.patch b/patches/none_network.patch new file mode 100644 index 00000000..141e1d20 --- /dev/null +++ b/patches/none_network.patch @@ -0,0 +1,260 @@ +diff --git a/cmd/nerdctl/container_run_network_linux_test.go b/cmd/nerdctl/container_run_network_linux_test.go +index 95fdbc41..8706a20d 100644 +--- a/cmd/nerdctl/container_run_network_linux_test.go ++++ b/cmd/nerdctl/container_run_network_linux_test.go +@@ -451,6 +451,72 @@ func TestSharedNetworkStack(t *testing.T) { + AssertOutContains(testutil.NginxAlpineIndexHTMLSnippet) + } + ++func TestSharedNetworkWithNone(t *testing.T) { ++ if runtime.GOOS != "linux" { ++ t.Skip("--network=container: only supports linux now") ++ } ++ base := testutil.NewBase(t) ++ ++ containerName := testutil.Identifier(t) ++ defer base.Cmd("rm", "-f", containerName).AssertOK() ++ base.Cmd("run", "-d", "--name", containerName, "--network", "none", ++ testutil.NginxAlpineImage).AssertOK() ++ base.EnsureContainerStarted(containerName) ++ ++ containerNameJoin := testutil.Identifier(t) + "-network" ++ defer base.Cmd("rm", "-f", containerNameJoin).AssertOK() ++ base.Cmd("run", ++ "-d", ++ "--name", containerNameJoin, ++ "--network=container:"+containerName, ++ testutil.CommonImage, ++ "sleep", "infinity").AssertOK() ++ ++ base.Cmd("exec", containerNameJoin, "wget", "-qO-", "http://127.0.0.1:80"). ++ AssertOutContains(testutil.NginxAlpineIndexHTMLSnippet) ++ ++ base.Cmd("restart", containerName).AssertOK() ++ base.Cmd("stop", "--time=1", containerNameJoin).AssertOK() ++ base.Cmd("start", containerNameJoin).AssertOK() ++ base.Cmd("exec", containerNameJoin, "wget", "-qO-", "http://127.0.0.1:80"). ++ AssertOutContains(testutil.NginxAlpineIndexHTMLSnippet) ++} ++ ++func TestRunContainerInExistingNetNS(t *testing.T) { ++ if rootlessutil.IsRootless() { ++ t.Skip("Can't create new netns in rootless mode") ++ } ++ testutil.DockerIncompatible(t) ++ base := testutil.NewBase(t) ++ ++ netNS, err := netns.NewNetNS(t.TempDir() + "/netns") ++ assert.NilError(t, err) ++ err = netNS.Do(func(netns ns.NetNS) error { ++ loopback, err := netlink.LinkByName("lo") ++ assert.NilError(t, err) ++ err = netlink.LinkSetUp(loopback) ++ assert.NilError(t, err) ++ return nil ++ }) ++ assert.NilError(t, err) ++ defer netNS.Remove() ++ ++ containerName := testutil.Identifier(t) ++ defer base.Cmd("rm", "-f", containerName).AssertOK() ++ base.Cmd("run", "-d", "--name", containerName, ++ "--network=ns:"+netNS.GetPath(), testutil.NginxAlpineImage).AssertOK() ++ base.EnsureContainerStarted(containerName) ++ time.Sleep(3 * time.Second) ++ ++ err = netNS.Do(func(netns ns.NetNS) error { ++ stdout, err := exec.Command("curl", "-s", "http://127.0.0.1:80").Output() ++ assert.NilError(t, err) ++ assert.Assert(t, strings.Contains(string(stdout), testutil.NginxAlpineIndexHTMLSnippet)) ++ return nil ++ }) ++ assert.NilError(t, err) ++} ++ + func TestRunContainerWithMACAddress(t *testing.T) { + base := testutil.NewBase(t) + tID := testutil.Identifier(t) +@@ -511,6 +577,8 @@ func TestHostsFileMounts(t *testing.T) { + "sh", "-euxc", "echo >> /etc/hosts").AssertOK() + base.Cmd("run", "--rm", "-v", "/etc/hosts:/etc/hosts", "--network", "host", testutil.CommonImage, + "sh", "-euxc", "head -n -1 /etc/hosts > temp && cat temp > /etc/hosts").AssertOK() ++ base.Cmd("run", "--rm", "--network", "none", testutil.CommonImage, ++ "sh", "-euxc", "echo >> /etc/hosts").AssertOK() + + base.Cmd("run", "--rm", testutil.CommonImage, + "sh", "-euxc", "echo >> /etc/resolv.conf").AssertOK() +@@ -523,6 +591,8 @@ func TestHostsFileMounts(t *testing.T) { + "sh", "-euxc", "echo >> /etc/resolv.conf").AssertOK() + base.Cmd("run", "--rm", "-v", "/etc/resolv.conf:/etc/resolv.conf", "--network", "host", testutil.CommonImage, + "sh", "-euxc", "head -n -1 /etc/resolv.conf > temp && cat temp > /etc/resolv.conf").AssertOK() ++ base.Cmd("run", "--rm", "--network", "host", testutil.CommonImage, ++ "sh", "-euxc", "echo >> /etc/resolv.conf").AssertOK() + } + + func TestRunContainerWithStaticIP6(t *testing.T) { +@@ -594,3 +664,42 @@ func TestRunContainerWithStaticIP6(t *testing.T) { + }) + } + } ++ ++func TestNoneNetworkStaticConfigs(t *testing.T) { ++ testutil.DockerIncompatible(t) ++ base := testutil.NewBase(t) ++ ++ cmd := base.Cmd("run", "--rm", "--net", "none", testutil.CommonImage, "cat", "/etc/hosts") ++ cmd.AssertOutContains("127.0.0.1 localhost") ++ cmd.AssertOutContains("::1 localhost") ++ ++ cmd = base.Cmd("run", "--rm", "--net", "none", testutil.CommonImage, "cat", "/etc/resolv.conf") ++ cmd.AssertOutContains("nameserver 127.0.0.1") ++ ++ // If running on Linux, verify /etc/hostname is correctly set ++ if runtime.GOOS == "linux" { ++ containerHostName := "testcontainer" ++ cmd := base.Cmd("run", "--rm", "--net", "none", "--hostname", containerHostName, testutil.CommonImage, "cat", "/etc/hostname") ++ output := cmd.Run().Combined() ++ hostname := strings.TrimSpace(output) ++ ++ if len(containerHostName) > 12 { ++ require.Equal(t, containerHostName[:12], hostname) ++ } else { ++ require.Equal(t, containerHostName, hostname) ++ } ++ ++ containerName := "testNoneNetworkHostname" ++ defer base.Cmd("rm", "-f", containerName).AssertOK() ++ cmd = base.Cmd("run", "--d", "--net", "none", "--name", containerName, testutil.CommonImage, "sleep", "infinity") ++ output = cmd.Run().Combined() ++ containerIDShort := strings.TrimSpace(output)[:12] ++ ++ cmd = base.Cmd("exec", containerName, "cat", "/etc/hostname") ++ output = cmd.Run().Combined() ++ containerHostName = strings.TrimSpace(output) ++ ++ require.Equal(t, containerHostName, containerIDShort) ++ ++ } ++} +diff --git a/go.mod b/go.mod +index e43c1699..9f720a80 100644 +--- a/go.mod ++++ b/go.mod +@@ -120,7 +120,7 @@ require ( + github.com/pelletier/go-toml v1.9.5 // indirect + github.com/philhofer/fwd v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect +- github.com/sirupsen/logrus v1.9.3 // indirect ++ github.com/sirupsen/logrus v1.9.3 + github.com/spaolacci/murmur3 v1.1.0 // indirect + github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6 // indirect + github.com/tidwall/match v1.1.1 // indirect +diff --git a/go.sum b/go.sum +index 723f2a36..33ae6f3d 100644 +--- a/go.sum ++++ b/go.sum +@@ -275,8 +275,9 @@ github.com/philhofer/fwd v1.1.1 h1:GdGcTjf5RNAxwS4QLsiMzJYj5KEvPJD3Abr261yRQXQ= + github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= + github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= + github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= ++github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= ++github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= + github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= + github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI= + github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= +diff --git a/pkg/containerutil/container_network_manager.go b/pkg/containerutil/container_network_manager.go +index b86870a4..ee82eedc 100644 +--- a/pkg/containerutil/container_network_manager.go ++++ b/pkg/containerutil/container_network_manager.go +@@ -38,6 +38,7 @@ import ( + "github.com/containerd/nerdctl/pkg/mountutil" + "github.com/containerd/nerdctl/pkg/netutil" + "github.com/containerd/nerdctl/pkg/netutil/nettype" ++ "github.com/containerd/nerdctl/pkg/resolvconf" + "github.com/containerd/nerdctl/pkg/strutil" + "github.com/opencontainers/runtime-spec/specs-go" + ) +@@ -169,11 +170,79 @@ func (m *noneNetworkManager) InternalNetworkingOptionLabels(_ context.Context) ( + return m.netOpts, nil + } + ++// WriteContentToHostsFile writes the given content to the specified path. ++func WriteContentToHostsFile(path string, content []byte) error { ++ // Write the content to the specified path (overwrites if the file exists) ++ if err := os.WriteFile(path, content, 0644); err != nil { ++ return err ++ } ++ return nil ++} ++ + // ContainerNetworkingOpts Returns a slice of `oci.SpecOpts` and `containerd.NewContainerOpts` which represent + // the network specs which need to be applied to the container with the given ID. +-func (m *noneNetworkManager) ContainerNetworkingOpts(_ context.Context, _ string) ([]oci.SpecOpts, []containerd.NewContainerOpts, error) { ++func (m *noneNetworkManager) ContainerNetworkingOpts(_ context.Context, containerID string) ([]oci.SpecOpts, []containerd.NewContainerOpts, error) { + // No options to return if no network settings are provided. +- return []oci.SpecOpts{}, []containerd.NewContainerOpts{}, nil ++ dataStore, err := clientutil.DataStore(m.globalOptions.DataRoot, m.globalOptions.Address) ++ if err != nil { ++ return nil, nil, err ++ } ++ ++ stateDir, err := ContainerStateDirPath(m.globalOptions.Namespace, dataStore, containerID) ++ if err != nil { ++ return nil, nil, err ++ } ++ ++ resolvConfPath := filepath.Join(stateDir, "resolv.conf") ++ dns := []string{"127.0.0.1"} ++ dnsSearch := []string{} ++ dnsOptions := []string{} ++ ++ // Call the Build function ++ _, err = resolvconf.Build(resolvConfPath, dns, dnsSearch, dnsOptions) ++ if err != nil { ++ return nil, nil, err ++ } ++ ++ content := []byte(`127.0.0.1 localhost ++::1 localhost ++`) ++ ++ etcHostsPath, err := hostsstore.AllocHostsFile(dataStore, m.globalOptions.Namespace, containerID) ++ if err != nil { ++ return nil, nil, err ++ } ++ ++ if err := WriteContentToHostsFile(etcHostsPath, content); err != nil { ++ return nil, nil, err ++ } ++ ++ specs := []oci.SpecOpts{ ++ withDedupMounts("/etc/hosts", withCustomHosts(etcHostsPath)), ++ withDedupMounts("/etc/resolv.conf", withCustomResolvConf(resolvConfPath)), ++ } ++ ++ // `/etc/hostname` does not exist on FreeBSD ++ if runtime.GOOS == "linux" { ++ // If no hostname is set, default to first 12 characters of the container ID. ++ hostname := m.netOpts.Hostname ++ if hostname == "" { ++ hostname = containerID ++ if len(hostname) > 12 { ++ hostname = hostname[0:12] ++ } ++ } ++ m.netOpts.Hostname = hostname ++ ++ hostnameOpts, err := writeEtcHostnameForContainer(m.globalOptions, m.netOpts.Hostname, containerID) ++ if err != nil { ++ return nil, nil, err ++ } ++ if hostnameOpts != nil { ++ specs = append(specs, hostnameOpts...) ++ } ++ } ++ return specs, []containerd.NewContainerOpts{}, nil + } + + // types.NetworkOptionsManager implementation for container networking settings. diff --git a/patches/userns.patch b/patches/userns.patch new file mode 100644 index 00000000..e731c406 --- /dev/null +++ b/patches/userns.patch @@ -0,0 +1,1200 @@ +diff --git a/cmd/nerdctl/container_create.go b/cmd/nerdctl/container_create.go +index 60b7de34..cca5aaf4 100644 +--- a/cmd/nerdctl/container_create.go ++++ b/cmd/nerdctl/container_create.go +@@ -390,6 +390,10 @@ func processContainerCreateOptions(cmd *cobra.Command) (opt types.ContainerCreat + } + // #endregion + ++ opt.Userns, err = cmd.Flags().GetString("userns") ++ if err != nil { ++ return ++ } + // #region for image pull and verify options + imageVerifyOpt, err := processImageVerifyOptions(cmd) + if err != nil { +diff --git a/cmd/nerdctl/container_run.go b/cmd/nerdctl/container_run.go +index 5eff72b3..82c60910 100644 +--- a/cmd/nerdctl/container_run.go ++++ b/cmd/nerdctl/container_run.go +@@ -269,6 +269,8 @@ func setCreateFlags(cmd *cobra.Command) { + cmd.Flags().String("ipfs-address", "", "multiaddr of IPFS API (default uses $IPFS_PATH env variable if defined or local directory ~/.ipfs)") + + cmd.Flags().String("isolation", "default", "Specify isolation technology for container. On Linux the only valid value is default. Windows options are host, process and hyperv with process isolation as the default") ++ cmd.Flags().String("userns", "", "Support idmapping of containers") ++ cmd.Flags().Lookup("userns").Hidden = true + cmd.RegisterFlagCompletionFunc("isolation", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if runtime.GOOS == "windows" { + return []string{"default", "host", "process", "hyperv"}, cobra.ShellCompDirectiveNoFileComp +diff --git a/cmd/nerdctl/container_run_linux_test.go b/cmd/nerdctl/container_run_linux_test.go +index 0900489f..d3dd5700 100644 +--- a/cmd/nerdctl/container_run_linux_test.go ++++ b/cmd/nerdctl/container_run_linux_test.go +@@ -25,6 +25,7 @@ import ( + "io" + "net/http" + "os" ++ "os/exec" + "path/filepath" + "strconv" + "strings" +@@ -35,6 +36,7 @@ import ( + "github.com/containerd/nerdctl/pkg/rootlessutil" + "github.com/containerd/nerdctl/pkg/strutil" + "github.com/containerd/nerdctl/pkg/testutil" ++ "github.com/sirupsen/logrus" + "gotest.tools/v3/assert" + "gotest.tools/v3/icmd" + ) +@@ -327,6 +329,158 @@ func TestRunSigProxy(t *testing.T) { + } + } + ++func TestUsernsMapping(t *testing.T) { ++ t.Parallel() ++ ++ image := testutil.CommonImage ++ processCmd := "sleep 30" ++ validUserns := "nerdctltestuser" ++ invalidUserns := "nonexistentuser" ++ expectedHostUID := 123456789 //setting an arbitary number to reduce collision ++ ++ defer removeUsernsConfig(validUserns, expectedHostUID) ++ ++ t.Run("validUserns", func(t *testing.T) { ++ if err := appendUsernsConfig(validUserns, expectedHostUID); err != nil { ++ t.Fatalf("Failed to append userns config: %v", err) ++ } ++ ++ containerName := testutil.Identifier(t) ++ defer removeContainer(t, containerName) ++ ++ result := runUsernsContainer(t, containerName, validUserns, image, processCmd) ++ fmt.Printf(result.Combined()) ++ assert.Assert(t, result.ExitCode == 0) ++ ++ actualHostUID, err := getContainerHostUID(t, containerName) ++ if err != nil { ++ t.Fatalf("Failed to get container host UID: %v", err) ++ } ++ ++ if actualHostUID != expectedHostUID { ++ t.Fatalf("Expected host UID %d, got %d", expectedHostUID, actualHostUID) ++ } ++ ++ t.Logf("Valid userns test passed: container mapped to host UID %d", actualHostUID) ++ }) ++ ++ t.Run("invalidUserns", func(t *testing.T) { ++ containerName := testutil.Identifier(t) ++ ++ result := runUsernsContainer(t, containerName, invalidUserns, image, processCmd) ++ assert.Assert(t, result.ExitCode != 0) ++ ++ }) ++} ++ ++func runUsernsContainer(t *testing.T, name, userns, image, cmd string) *icmd.Result { ++ base := testutil.NewBase(t) ++ removeContainerArgs := []string{ ++ "rm", "-f", name, ++ } ++ base.Cmd(removeContainerArgs...).Run() ++ ++ args := []string{ ++ "run", "-d", "--userns", userns, "--name", name, image, "sh", "-c", cmd, ++ } ++ return base.Cmd(args...).Run() ++} ++ ++func getContainerHostUID(t *testing.T, containerName string) (int, error) { ++ base := testutil.NewBase(t) ++ result := base.Cmd("inspect", "--format", "{{.State.Pid}}", containerName).Run() ++ if result.Error != nil { ++ return 0, fmt.Errorf("failed to get container PID: %v", result.Error) ++ } ++ ++ pidStr := strings.TrimSpace(result.Stdout()) ++ pid, err := strconv.Atoi(pidStr) ++ if err != nil { ++ return 0, fmt.Errorf("invalid PID: %v", err) ++ } ++ ++ stat, err := os.Stat(fmt.Sprintf("/proc/%d", pid)) ++ if err != nil { ++ return 0, fmt.Errorf("failed to stat process: %v", err) ++ } ++ ++ uid := int(stat.Sys().(*syscall.Stat_t).Uid) ++ return uid, nil ++} ++ ++func appendUsernsConfig(userns string, hostUid int) error { ++ if err := addUser(userns, hostUid); err != nil { ++ return fmt.Errorf("failed to add user %s: %w", userns, err) ++ } ++ ++ entry := fmt.Sprintf("%s:%d:65536\n", userns, hostUid) ++ ++ files := []string{"/etc/subuid", "/etc/subgid"} ++ for _, file := range files { ++ f, err := os.OpenFile(file, os.O_APPEND|os.O_WRONLY, 0644) ++ if err != nil { ++ return fmt.Errorf("failed to open %s: %w", file, err) ++ } ++ defer f.Close() ++ ++ if _, err := f.WriteString(entry); err != nil { ++ return fmt.Errorf("failed to write to %s: %w", file, err) ++ } ++ } ++ return nil ++} ++ ++func addUser(username string, hostId int) error { ++ cmd := exec.Command("sudo", "groupadd", "-g", strconv.Itoa(hostId), username) ++ output, err := cmd.CombinedOutput() ++ if err != nil { ++ return fmt.Errorf("groupadd failed: %s, %w", string(output), err) ++ } ++ cmd = exec.Command("sudo", "useradd", "-u", strconv.Itoa(hostId), "-g", strconv.Itoa(hostId), "-s", "/bin/false", username) ++ output, err = cmd.CombinedOutput() ++ if err != nil { ++ return fmt.Errorf("useradd failed: %s, %w", string(output), err) ++ } ++ return nil ++} ++ ++func removeUsernsConfig(userns string, hostUid int) { ++ if err := delUser(userns); err != nil { ++ logrus.Errorf("failed to del user %s", userns) ++ return ++ } ++ ++ entry := fmt.Sprintf("%s:%d:65536\n", userns, hostUid) ++ ++ files := []string{"/etc/subuid", "/etc/subgid"} ++ for _, file := range files { ++ content, err := os.ReadFile(file) ++ if err != nil { ++ logrus.Errorf("Failed to read %s: %v", file, err) ++ continue ++ } ++ ++ newContent := strings.ReplaceAll(string(content), entry, "") ++ if err := os.WriteFile(file, []byte(newContent), 0644); err != nil { ++ logrus.Errorf("Failed to write to %s: %v", file, err) ++ } ++ } ++} ++ ++func delUser(username string) error { ++ cmd := exec.Command("sudo", "userdel", username) ++ output, err := cmd.CombinedOutput() ++ if err != nil { ++ return fmt.Errorf("userdel failed: %s, %w", string(output), err) ++ } ++ return nil ++} ++ ++func removeContainer(t *testing.T, name string) { ++ base := testutil.NewBase(t) ++ base.Cmd("rm", "-f", name).Run() ++} ++ + func TestRunWithFluentdLogDriver(t *testing.T) { + base := testutil.NewBase(t) + tempDirectory := t.TempDir() +diff --git a/go.mod b/go.mod +index e43c1699..c2c92cea 100644 +--- a/go.mod ++++ b/go.mod +@@ -38,6 +38,7 @@ require ( + github.com/ipfs/go-cid v0.4.1 + github.com/mattn/go-isatty v0.0.20 + github.com/mitchellh/mapstructure v1.5.0 ++ github.com/moby/moby v27.3.1+incompatible + github.com/moby/sys/mount v0.3.3 + github.com/moby/sys/signal v0.7.0 + github.com/moby/term v0.5.0 +@@ -50,6 +51,7 @@ require ( + github.com/rootless-containers/rootlesskit v1.1.1 + github.com/spf13/cobra v1.8.0 + github.com/spf13/pflag v1.0.5 ++ github.com/stretchr/testify v1.8.4 + github.com/tidwall/gjson v1.17.0 + github.com/vishvananda/netlink v1.2.1-beta.2 + github.com/vishvananda/netns v0.0.4 +@@ -65,6 +67,11 @@ require ( + gotest.tools/v3 v3.5.1 + ) + ++require ( ++ github.com/davecgh/go-spew v1.1.1 // indirect ++ github.com/pmezard/go-difflib v1.0.0 // indirect ++) ++ + require ( + github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect + github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0 // indirect +diff --git a/go.sum b/go.sum +index 723f2a36..de61f612 100644 +--- a/go.sum ++++ b/go.sum +@@ -212,6 +212,8 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua + github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= + github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= + github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= ++github.com/moby/moby v27.3.1+incompatible h1:KQbXBjo7PavKpzIl7UkHT31y9lw/e71Uvrqhr4X+zMA= ++github.com/moby/moby v27.3.1+incompatible/go.mod h1:fDXVQ6+S340veQPv35CzDahGBmHsiclFwfEygB/TWMc= + github.com/moby/sys/mount v0.3.3 h1:fX1SVkXFJ47XWDoeFW4Sq7PdQJnV2QIDZAqjNqgEjUs= + github.com/moby/sys/mount v0.3.3/go.mod h1:PBaEorSNTLG5t/+4EgukEQVlAvVEc6ZjTySwKdqp5K0= + github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78= +diff --git a/pkg/api/types/container_types.go b/pkg/api/types/container_types.go +index b9e64757..fc11e7e0 100644 +--- a/pkg/api/types/container_types.go ++++ b/pkg/api/types/container_types.go +@@ -259,6 +259,9 @@ type ContainerCreateOptions struct { + + // ImagePullOpt specifies image pull options which holds the ImageVerifyOptions for verifying the image. + ImagePullOpt ImagePullOptions ++ ++ // Userns name for user namespace mapping of container ++ Userns string + } + + // ContainerStopOptions specifies options for `nerdctl (container) stop`. +diff --git a/pkg/cmd/container/create.go b/pkg/cmd/container/create.go +index 43f5e682..953c07b1 100644 +--- a/pkg/cmd/container/create.go ++++ b/pkg/cmd/container/create.go +@@ -128,6 +128,26 @@ func Create(ctx context.Context, client *containerd.Client, args []string, netMa + opts = append(opts, rootfsOpts...) + cOpts = append(cOpts, rootfsCOpts...) + ++ if options.Userns != "" { ++ userNameSpaceOpts, userNameSpaceCOpts, err := getUserNamespaceOpts(ctx, client, &options, *ensuredImage, id) ++ if err != nil { ++ return nil, nil, err ++ } ++ opts = append(opts, userNameSpaceOpts...) ++ cOpts = append(cOpts, userNameSpaceCOpts...) ++ ++ userNsOpts, err := getContainerUserNamespaceNetOpts(ctx, client, netManager) ++ if err != nil { ++ return nil, nil, err ++ } ++ if userNsOpts != nil { ++ opts = append(opts, userNsOpts...) ++ } ++ ++ } else { ++ cOpts = append(cOpts, containerd.WithNewSnapshot(id, ensuredImage.Image)) ++ } ++ + if options.Workdir != "" { + opts = append(opts, oci.WithProcessCwd(options.Workdir)) + } +@@ -316,7 +336,6 @@ func generateRootfsOpts(args []string, id string, ensured *imgutil.EnsuredImage, + cOpts = append(cOpts, + containerd.WithImage(ensured.Image), + containerd.WithSnapshotter(ensured.Snapshotter), +- containerd.WithNewSnapshot(id, ensured.Image), + containerd.WithImageStopSignal(ensured.Image, "SIGTERM"), + ) + +diff --git a/pkg/cmd/container/create_userns_opts_linux.go b/pkg/cmd/container/create_userns_opts_linux.go +new file mode 100644 +index 00000000..d9e903ce +--- /dev/null ++++ b/pkg/cmd/container/create_userns_opts_linux.go +@@ -0,0 +1,305 @@ ++/* ++ Copyright The containerd 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 container ++ ++import ( ++ "context" ++ "errors" ++ "fmt" ++ "strings" ++ ++ "github.com/containerd/containerd" ++ "github.com/containerd/containerd/oci" ++ "github.com/containerd/containerd/snapshots" ++ "github.com/containerd/nerdctl/pkg/api/types" ++ "github.com/containerd/nerdctl/pkg/containerutil" ++ "github.com/containerd/nerdctl/pkg/idutil/containerwalker" ++ "github.com/containerd/nerdctl/pkg/imgutil" ++ "github.com/containerd/nerdctl/pkg/netutil/nettype" ++ nerdctlUserns "github.com/containerd/nerdctl/pkg/userns" ++ "github.com/moby/moby/pkg/idtools" ++ "github.com/opencontainers/runtime-spec/specs-go" ++) ++ ++const ( ++ capabMultiRemapIDs = "multi-remap-ids" ++) ++ ++// getUserNamespaceOpts generates spec opts and container opts for usernamespace ++func getUserNamespaceOpts( ++ ctx context.Context, ++ client *containerd.Client, ++ options *types.ContainerCreateOptions, ++ ensuredImage imgutil.EnsuredImage, ++ id string, ++) ([]oci.SpecOpts, []containerd.NewContainerOpts, error) { ++ ++ idMapping, err := loadAndValidateIDMapping(options.Userns) ++ if err != nil { ++ return nil, nil, err ++ } ++ ++ supportsMultiRemap, err := checkSnapshotterSupport(ctx, client, ensuredImage.Snapshotter) ++ if err != nil { ++ return nil, nil, err ++ } ++ ++ uidMaps, gidMaps := convertMappings(idMapping) ++ specOpts := []oci.SpecOpts{oci.WithUserNamespace(uidMaps, gidMaps)} ++ ++ snapshotOpts, err := createSnapshotOpts(id, ensuredImage, uidMaps, gidMaps, supportsMultiRemap) ++ if err != nil { ++ return nil, nil, err ++ } ++ ++ return specOpts, snapshotOpts, nil ++} ++ ++// getContainerUserNamespaceNetOpts retrieves the user namespace opts for the specified network container. ++func getContainerUserNamespaceNetOpts( ++ ctx context.Context, ++ client *containerd.Client, ++ netManager containerutil.NetworkOptionsManager, ++) ([]oci.SpecOpts, error) { ++ netOpts, err := netManager.InternalNetworkingOptionLabels(ctx) ++ netType, err := nettype.Detect(netOpts.NetworkSlice) ++ if err != nil { ++ return nil, err ++ } else if netType != nettype.Host { ++ return []oci.SpecOpts{}, nil ++ ++ } ++ ++ containerName, err := getContainerNameFromNetworkSlice(netOpts) ++ if err != nil { ++ return nil, err ++ } ++ ++ container, err := findContainer(ctx, client, containerName) ++ if err != nil { ++ return nil, err ++ } ++ ++ if err := validateContainerStatus(ctx, container); err != nil { ++ return nil, err ++ } ++ ++ userNsPath, err := getUserNamespacePath(ctx, container) ++ if err != nil { ++ return nil, err ++ } ++ ++ var userNameSpaceSpecOpts []oci.SpecOpts ++ userNameSpaceSpecOpts = append(userNameSpaceSpecOpts, oci.WithLinuxNamespace(specs.LinuxNamespace{ ++ Type: specs.UserNamespace, ++ Path: userNsPath, ++ })) ++ return userNameSpaceSpecOpts, nil ++} ++ ++func convertIDMapToLinuxIDMapping(idMaps []idtools.IDMap) []specs.LinuxIDMapping { ++ linuxIDMappings := make([]specs.LinuxIDMapping, len(idMaps)) ++ ++ for i, idMap := range idMaps { ++ linuxIDMappings[i] = specs.LinuxIDMapping{ ++ ContainerID: uint32(idMap.ContainerID), ++ HostID: uint32(idMap.HostID), ++ Size: uint32(idMap.Size), ++ } ++ } ++ ++ return linuxIDMappings ++} ++ ++// withMultiRemapperLabels creates the labels used by any supporting snapshotter ++// to shift the filesystem ownership with multiple ranges of maps ++func withMultiRemapperLabels(uidmaps, gidmaps []specs.LinuxIDMapping) snapshots.Opt { ++ idMap := nerdctlUserns.IDMap{ ++ UidMap: uidmaps, ++ GidMap: gidmaps, ++ } ++ uidmapLabel, gidmapLabel := idMap.Marshal() ++ return snapshots.WithLabels(map[string]string{ ++ snapshots.LabelSnapshotUIDMapping: uidmapLabel, ++ snapshots.LabelSnapshotGIDMapping: gidmapLabel, ++ }) ++} ++ ++// findContainer searches for a container by name and returns it if found. ++func findContainer( ++ ctx context.Context, ++ client *containerd.Client, ++ containerName string, ++) (containerd.Container, error) { ++ var container containerd.Container ++ ++ walker := &containerwalker.ContainerWalker{ ++ Client: client, ++ OnFound: func(_ context.Context, found containerwalker.Found) error { ++ if found.MatchCount > 1 { ++ return fmt.Errorf("multiple containers found with prefix: %s", containerName) ++ } ++ container = found.Container ++ return nil ++ }, ++ } ++ ++ if n, err := walker.Walk(ctx, containerName); err != nil { ++ return container, err ++ } else if n == 0 { ++ return container, fmt.Errorf("container not found: %s", containerName) ++ } ++ ++ return container, nil ++} ++ ++// validateContainerStatus checks if the container is running. ++func validateContainerStatus(ctx context.Context, container containerd.Container) error { ++ task, err := container.Task(ctx, nil) ++ if err != nil { ++ return err ++ } ++ ++ status, err := task.Status(ctx) ++ if err != nil { ++ return err ++ } ++ ++ if status.Status != containerd.Running { ++ return fmt.Errorf("container %s is not running", container.ID()) ++ } ++ ++ return nil ++} ++ ++// getUserNamespacePath returns the path to the container's user namespace. ++func getUserNamespacePath(ctx context.Context, container containerd.Container) (string, error) { ++ task, err := container.Task(ctx, nil) ++ if err != nil { ++ return "", err ++ } ++ ++ return fmt.Sprintf("/proc/%d/ns/user", task.Pid()), nil ++} ++ ++// Creates default snapshot options. ++func createDefaultSnapshotOpts(id string, image imgutil.EnsuredImage) []containerd.NewContainerOpts { ++ return []containerd.NewContainerOpts{ ++ containerd.WithNewSnapshot(id, image.Image), ++ } ++} ++ ++// Loads and validates the ID mapping from the given Userns. ++func loadAndValidateIDMapping(userns string) (idtools.IdentityMapping, error) { ++ idMapping, err := idtools.LoadIdentityMapping(userns) ++ if err != nil { ++ return idtools.IdentityMapping{}, err ++ } ++ if !validIDMapping(idMapping) { ++ return idtools.IdentityMapping{}, errors.New("no valid UID/GID mappings found") ++ } ++ return idMapping, nil ++} ++ ++// Checks if the snapshotter supports multi-remap IDs. ++func checkSnapshotterSupport( ++ ctx context.Context, ++ client *containerd.Client, ++ snapshotter string, ++) (bool, error) { ++ return snapshotterSupportsMultiRemap(ctx, client, snapshotter) ++} ++ ++// Validates that both UID and GID mappings are available. ++func validIDMapping(mapping idtools.IdentityMapping) bool { ++ return len(mapping.UIDMaps) > 0 && len(mapping.GIDMaps) > 0 ++} ++ ++// Converts IDMapping into LinuxIDMapping structures. ++func convertMappings(mapping idtools.IdentityMapping) ([]specs.LinuxIDMapping, []specs.LinuxIDMapping) { ++ return convertIDMapToLinuxIDMapping(mapping.UIDMaps), ++ convertIDMapToLinuxIDMapping(mapping.GIDMaps) ++} ++ ++// Creates snapshot options based on ID mappings and snapshotter capabilities. ++func createSnapshotOpts( ++ id string, ++ image imgutil.EnsuredImage, ++ uidMaps, gidMaps []specs.LinuxIDMapping, ++ supportsMultiRemap bool, ++) ([]containerd.NewContainerOpts, error) { ++ if !isValidMapping(uidMaps, gidMaps) { ++ return nil, errors.New("snapshotter uidmap gidmap config invalid") ++ } ++ if isMultiMapping(uidMaps, gidMaps) { ++ if supportsMultiRemap { ++ return []containerd.NewContainerOpts{ ++ containerd.WithNewSnapshot(id, image.Image, withMultiRemapperLabels(uidMaps, gidMaps)), ++ }, nil ++ } ++ return nil, errors.New("snapshotter doesn't support multiple UID/GID remapping") ++ } ++ return []containerd.NewContainerOpts{ ++ containerd.WithNewSnapshot(id, image.Image, ++ containerd.WithRemapperLabels(0, uidMaps[0].HostID, 0, gidMaps[0].HostID, uidMaps[0].Size)), ++ }, nil ++} ++ ++// Checks if there are multiple mappings available. ++func isMultiMapping(uidMaps, gidMaps []specs.LinuxIDMapping) bool { ++ return len(uidMaps) > 1 || len(gidMaps) > 1 ++} ++ ++func isValidMapping(uidMaps, gidMaps []specs.LinuxIDMapping) bool { ++ return len(uidMaps) > 0 && len(gidMaps) > 0 ++} ++ ++// Helper function to check if the snapshotter supports multi-remap IDs. ++func snapshotterSupportsMultiRemap( ++ ctx context.Context, ++ client *containerd.Client, ++ snapshotterName string, ++) (bool, error) { ++ caps, err := client.GetSnapshotterCapabilities(ctx, snapshotterName) ++ if err != nil { ++ return false, err ++ } ++ return hasCapability(caps, capabMultiRemapIDs), nil ++} ++ ++// Checks if the given capability exists in the list. ++func hasCapability(caps []string, capability string) bool { ++ for _, cap := range caps { ++ if cap == capability { ++ return true ++ } ++ } ++ return false ++} ++ ++func getContainerNameFromNetworkSlice(netOpts types.NetworkOptions) (string, error) { ++ ++ netItems := strings.Split(netOpts.NetworkSlice[0], ":") ++ if len(netItems) < 2 { ++ return "", fmt.Errorf("container networking argument format must be 'container:', got: %q", netOpts.NetworkSlice[0]) ++ } else if len(netItems[1]) == 0 { ++ return "", fmt.Errorf("container name length invald, got length: 0") ++ } ++ containerName := netItems[1] ++ ++ return containerName, nil ++} +diff --git a/pkg/cmd/container/create_userns_opts_linux_test.go b/pkg/cmd/container/create_userns_opts_linux_test.go +new file mode 100644 +index 00000000..913eb4a9 +--- /dev/null ++++ b/pkg/cmd/container/create_userns_opts_linux_test.go +@@ -0,0 +1,143 @@ ++package container ++ ++import ( ++ "testing" ++ ++ "github.com/containerd/nerdctl/pkg/api/types" ++ "github.com/containerd/nerdctl/pkg/imgutil" ++ "github.com/opencontainers/runtime-spec/specs-go" ++ "github.com/stretchr/testify/assert" ++) ++ ++// TestCreateSnapshotOpts tests the createSnapshotOpts function. ++func TestCreateSnapshotOpts(t *testing.T) { ++ tests := []struct { ++ name string ++ id string ++ image imgutil.EnsuredImage ++ uidMaps []specs.LinuxIDMapping ++ gidMaps []specs.LinuxIDMapping ++ supportsMultiRemap bool ++ expectError bool ++ }{ ++ { ++ name: "Single remapping", ++ id: "container1", ++ image: imgutil.EnsuredImage{}, ++ uidMaps: []specs.LinuxIDMapping{ ++ {HostID: 1000, Size: 1}, ++ }, ++ gidMaps: []specs.LinuxIDMapping{ ++ {HostID: 1000, Size: 1}, ++ }, ++ supportsMultiRemap: false, ++ expectError: false, ++ }, ++ { ++ name: "Multi remapping with support", ++ id: "container2", ++ image: imgutil.EnsuredImage{}, ++ uidMaps: []specs.LinuxIDMapping{ ++ {HostID: 1000, Size: 1}, ++ {HostID: 2000, Size: 1}, ++ }, ++ gidMaps: []specs.LinuxIDMapping{ ++ {HostID: 3000, Size: 1}, ++ }, ++ supportsMultiRemap: true, ++ expectError: false, ++ }, ++ { ++ name: "Multi remapping without support", ++ id: "container3", ++ image: imgutil.EnsuredImage{}, ++ uidMaps: []specs.LinuxIDMapping{ ++ {HostID: 1000, Size: 1}, ++ {HostID: 2000, Size: 1}, ++ }, ++ gidMaps: []specs.LinuxIDMapping{ ++ {HostID: 3000, Size: 1}, ++ }, ++ supportsMultiRemap: false, ++ expectError: true, ++ }, ++ { ++ name: "Empty UID/GID maps", ++ id: "container4", ++ image: imgutil.EnsuredImage{}, ++ uidMaps: []specs.LinuxIDMapping{}, ++ gidMaps: []specs.LinuxIDMapping{}, ++ supportsMultiRemap: false, ++ expectError: true, ++ }, ++ } ++ ++ for _, tt := range tests { ++ t.Run(tt.name, func(t *testing.T) { ++ opts, err := createSnapshotOpts(tt.id, tt.image, tt.uidMaps, tt.gidMaps, tt.supportsMultiRemap) ++ ++ if tt.expectError { ++ assert.Error(t, err) ++ } else { ++ assert.NoError(t, err) ++ assert.NotNil(t, opts) ++ } ++ }) ++ } ++} ++ ++// TestGetContainerNameFromNetworkSlice tests the getContainerNameFromNetworkSlice function. ++func TestGetContainerNameFromNetworkSlice(t *testing.T) { ++ tests := []struct { ++ name string ++ netOpts types.NetworkOptions ++ expected string ++ expectError bool ++ }{ ++ { ++ name: "Valid input with container name", ++ netOpts: types.NetworkOptions{ ++ NetworkSlice: []string{"container:mycontainer"}, ++ }, ++ expected: "mycontainer", ++ expectError: false, ++ }, ++ { ++ name: "Invalid input with no colon separator", ++ netOpts: types.NetworkOptions{ ++ NetworkSlice: []string{"container-mycontainer"}, ++ }, ++ expected: "", ++ expectError: true, ++ }, ++ { ++ name: "Empty NetworkSlice", ++ netOpts: types.NetworkOptions{ ++ NetworkSlice: []string{""}, ++ }, ++ expected: "", ++ expectError: true, ++ }, ++ { ++ name: "Missing container name", ++ netOpts: types.NetworkOptions{ ++ NetworkSlice: []string{"container:"}, ++ }, ++ expected: "", ++ expectError: true, ++ }, ++ } ++ ++ for _, tt := range tests { ++ t.Run(tt.name, func(t *testing.T) { ++ containerName, err := getContainerNameFromNetworkSlice(tt.netOpts) ++ ++ if tt.expectError { ++ assert.Error(t, err) ++ } else { ++ assert.NoError(t, err) ++ assert.Equal(t, tt.expected, containerName) ++ } ++ }) ++ } ++} +diff --git a/pkg/userns/idmap.go b/pkg/userns/idmap.go +new file mode 100644 +index 00000000..caa3c311 +--- /dev/null ++++ b/pkg/userns/idmap.go +@@ -0,0 +1,171 @@ ++/* ++ Copyright The containerd 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. ++*/ ++ ++/* ++ This file is copied and customized based on ++ https://github.com/moby/moby/blob/master/pkg/idtools/idtools.go ++*/ ++ ++package userns ++ ++import ( ++ "errors" ++ "fmt" ++ "strings" ++ ++ "github.com/opencontainers/runtime-spec/specs-go" ++) ++ ++const invalidID = 1<<32 - 1 ++ ++var invalidUser = User{Uid: invalidID, Gid: invalidID} ++ ++// User is a Uid and Gid pair of a user ++// ++//nolint:revive ++type User struct { ++ Uid uint32 ++ Gid uint32 ++} ++ ++// IDMap contains the mappings of Uids and Gids. ++// ++//nolint:revive ++type IDMap struct { ++ UidMap []specs.LinuxIDMapping `json:"UidMap"` ++ GidMap []specs.LinuxIDMapping `json:"GidMap"` ++} ++ ++// RootPair returns the ID pair for the root user ++func (i *IDMap) RootPair() (User, error) { ++ uid, err := toHost(0, i.UidMap) ++ if err != nil { ++ return invalidUser, err ++ } ++ gid, err := toHost(0, i.GidMap) ++ if err != nil { ++ return invalidUser, err ++ } ++ return User{Uid: uid, Gid: gid}, nil ++} ++ ++// ToHost returns the host user ID pair for the container ID pair. ++func (i IDMap) ToHost(pair User) (User, error) { ++ var ( ++ target User ++ err error ++ ) ++ target.Uid, err = toHost(pair.Uid, i.UidMap) ++ if err != nil { ++ return invalidUser, err ++ } ++ target.Gid, err = toHost(pair.Gid, i.GidMap) ++ if err != nil { ++ return invalidUser, err ++ } ++ return target, nil ++} ++ ++// toHost takes an id mapping and a remapped ID, and translates the ++// ID to the mapped host ID. If no map is provided, then the translation ++// assumes a 1-to-1 mapping and returns the passed in id # ++func toHost(contID uint32, idMap []specs.LinuxIDMapping) (uint32, error) { ++ if idMap == nil { ++ return contID, nil ++ } ++ for _, m := range idMap { ++ high, err := safeSum(m.ContainerID, m.Size) ++ if err != nil { ++ break ++ } ++ if contID >= m.ContainerID && contID < high { ++ hostID, err := safeSum(m.HostID, contID-m.ContainerID) ++ if err != nil || hostID == invalidID { ++ break ++ } ++ return hostID, nil ++ } ++ } ++ return invalidID, fmt.Errorf("container ID %d cannot be mapped to a host ID", contID) ++} ++ ++// safeSum returns the sum of x and y. or an error if the result overflows ++func safeSum(x, y uint32) (uint32, error) { ++ z := x + y ++ if z < x || z < y { ++ return invalidID, errors.New("ID overflow") ++ } ++ return z, nil ++} ++ ++func (i *IDMap) Marshal() (string, string) { ++ marshal := func(mappings []specs.LinuxIDMapping) string { ++ var arr []string ++ for _, m := range mappings { ++ arr = append(arr, serializeLinuxIDMapping(m)) ++ } ++ return strings.Join(arr, ",") ++ } ++ return marshal(i.UidMap), marshal(i.GidMap) ++} ++ ++func (i *IDMap) Unmarshal(uidMap, gidMap string) error { ++ unmarshal := func(str string, fn func(m specs.LinuxIDMapping)) error { ++ if len(str) == 0 { ++ return nil ++ } ++ for _, mapping := range strings.Split(str, ",") { ++ m, err := deserializeLinuxIDMapping(mapping) ++ if err != nil { ++ return err ++ } ++ fn(m) ++ } ++ return nil ++ } ++ if err := unmarshal(uidMap, func(m specs.LinuxIDMapping) { ++ i.UidMap = append(i.UidMap, m) ++ }); err != nil { ++ return err ++ } ++ return unmarshal(gidMap, func(m specs.LinuxIDMapping) { ++ i.GidMap = append(i.GidMap, m) ++ }) ++} ++ ++// serializeLinuxIDMapping marshals a LinuxIDMapping object to string ++func serializeLinuxIDMapping(m specs.LinuxIDMapping) string { ++ return fmt.Sprintf("%d:%d:%d", m.ContainerID, m.HostID, m.Size) ++} ++ ++// deserializeLinuxIDMapping unmarshals a string to a LinuxIDMapping object ++func deserializeLinuxIDMapping(str string) (specs.LinuxIDMapping, error) { ++ var ( ++ hostID, ctrID, length int64 ++ ) ++ _, err := fmt.Sscanf(str, "%d:%d:%d", &ctrID, &hostID, &length) ++ if err != nil { ++ return specs.LinuxIDMapping{}, fmt.Errorf("input value %s unparsable: %w", str, err) ++ } ++ if ctrID < 0 || ctrID >= invalidID || hostID < 0 || hostID >= invalidID || length < 0 || length >= invalidID { ++ return specs.LinuxIDMapping{}, fmt.Errorf("invalid mapping \"%s\"", str) ++ } ++ return specs.LinuxIDMapping{ ++ ContainerID: uint32(ctrID), ++ HostID: uint32(hostID), ++ Size: uint32(length), ++ }, nil ++} +diff --git a/pkg/userns/idmap_test.go b/pkg/userns/idmap_test.go +new file mode 100644 +index 00000000..30375ad6 +--- /dev/null ++++ b/pkg/userns/idmap_test.go +@@ -0,0 +1,252 @@ ++/* ++ Copyright The containerd 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 userns ++ ++import ( ++ "testing" ++ ++ "github.com/opencontainers/runtime-spec/specs-go" ++ "github.com/stretchr/testify/assert" ++) ++ ++func TestToHost(t *testing.T) { ++ idmap := IDMap{ ++ UidMap: []specs.LinuxIDMapping{ ++ { ++ ContainerID: 0, ++ HostID: 1, ++ Size: 2, ++ }, ++ { ++ ContainerID: 2, ++ HostID: 4, ++ Size: 1000, ++ }, ++ }, ++ GidMap: []specs.LinuxIDMapping{ ++ { ++ ContainerID: 0, ++ HostID: 2, ++ Size: 4, ++ }, ++ { ++ ContainerID: 4, ++ HostID: 8, ++ Size: 1000, ++ }, ++ }, ++ } ++ for _, test := range []struct { ++ container User ++ host User ++ }{ ++ { ++ container: User{ ++ Uid: 0, ++ Gid: 0, ++ }, ++ host: User{ ++ Uid: 1, ++ Gid: 2, ++ }, ++ }, ++ { ++ container: User{ ++ Uid: 1, ++ Gid: 1, ++ }, ++ host: User{ ++ Uid: 2, ++ Gid: 3, ++ }, ++ }, ++ { ++ container: User{ ++ Uid: 2, ++ Gid: 4, ++ }, ++ host: User{ ++ Uid: 4, ++ Gid: 8, ++ }, ++ }, ++ { ++ container: User{ ++ Uid: 100, ++ Gid: 200, ++ }, ++ host: User{ ++ Uid: 102, ++ Gid: 204, ++ }, ++ }, ++ { ++ container: User{ ++ Uid: 1001, ++ Gid: 1003, ++ }, ++ host: User{ ++ Uid: 1003, ++ Gid: 1007, ++ }, ++ }, ++ { ++ container: User{ ++ Uid: 1004, ++ Gid: 1008, ++ }, ++ host: invalidUser, ++ }, ++ { ++ container: User{ ++ Uid: 2000, ++ Gid: 2000, ++ }, ++ host: invalidUser, ++ }, ++ } { ++ r, err := idmap.ToHost(test.container) ++ assert.Equal(t, test.host, r) ++ if r == invalidUser { ++ assert.Error(t, err) ++ } else { ++ assert.NoError(t, err) ++ } ++ } ++} ++ ++func TestToHostOverflow(t *testing.T) { ++ for _, test := range []struct { ++ idmap IDMap ++ user User ++ }{ ++ { ++ idmap: IDMap{ ++ UidMap: []specs.LinuxIDMapping{ ++ { ++ ContainerID: 1<<32 - 1000, ++ HostID: 1000, ++ Size: 10000, ++ }, ++ }, ++ GidMap: []specs.LinuxIDMapping{ ++ { ++ ContainerID: 0, ++ HostID: 1000, ++ Size: 10000, ++ }, ++ }, ++ }, ++ user: User{ ++ Uid: 1<<32 - 100, ++ Gid: 0, ++ }, ++ }, ++ { ++ idmap: IDMap{ ++ UidMap: []specs.LinuxIDMapping{ ++ { ++ ContainerID: 0, ++ HostID: 1000, ++ Size: 10000, ++ }, ++ }, ++ GidMap: []specs.LinuxIDMapping{ ++ { ++ ContainerID: 1<<32 - 1000, ++ HostID: 1000, ++ Size: 10000, ++ }, ++ }, ++ }, ++ user: User{ ++ Uid: 0, ++ Gid: 1<<32 - 100, ++ }, ++ }, ++ { ++ idmap: IDMap{ ++ UidMap: []specs.LinuxIDMapping{ ++ { ++ ContainerID: 0, ++ HostID: 1000, ++ Size: 1<<32 - 1, ++ }, ++ }, ++ GidMap: []specs.LinuxIDMapping{ ++ { ++ ContainerID: 0, ++ HostID: 1000, ++ Size: 1<<32 - 1, ++ }, ++ }, ++ }, ++ user: User{ ++ Uid: 1<<32 - 2, ++ Gid: 0, ++ }, ++ }, ++ { ++ idmap: IDMap{ ++ UidMap: []specs.LinuxIDMapping{ ++ { ++ ContainerID: 0, ++ HostID: 1000, ++ Size: 1<<32 - 1, ++ }, ++ }, ++ GidMap: []specs.LinuxIDMapping{ ++ { ++ ContainerID: 0, ++ HostID: 1000, ++ Size: 1<<32 - 1, ++ }, ++ }, ++ }, ++ user: User{ ++ Uid: 0, ++ Gid: 1<<32 - 2, ++ }, ++ }, ++ { ++ idmap: IDMap{ ++ UidMap: []specs.LinuxIDMapping{ ++ { ++ ContainerID: 0, ++ HostID: 1, ++ Size: 1<<32 - 1, ++ }, ++ }, ++ GidMap: []specs.LinuxIDMapping{ ++ { ++ ContainerID: 0, ++ HostID: 1, ++ Size: 1<<32 - 1, ++ }, ++ }, ++ }, ++ user: User{ ++ Uid: 1<<32 - 2, ++ Gid: 1<<32 - 2, ++ }, ++ }, ++ } { ++ r, err := test.idmap.ToHost(test.user) ++ assert.Error(t, err) ++ assert.Equal(t, r, invalidUser) ++ } ++}