package daemon import ( "fmt" "os" "path/filepath" "strings" "testing" is "gotest.tools/v3/assert/cmp" "gotest.tools/v3/fs" containertypes "github.com/docker/docker/api/types/container" "github.com/docker/docker/container" swarmagent "github.com/moby/swarmkit/v2/agent" swarmapi "github.com/moby/swarmkit/v2/api" specs "github.com/opencontainers/runtime-spec/specs-go" "golang.org/x/sys/windows/registry" "gotest.tools/v3/assert" ) func TestSetWindowsCredentialSpecInSpec(t *testing.T) { // we need a temp directory to act as the daemon's root tmpDaemonRoot := fs.NewDir(t, t.Name()).Path() defer func() { assert.NilError(t, os.RemoveAll(tmpDaemonRoot)) }() daemon := &Daemon{ root: tmpDaemonRoot, } t.Run("it does nothing if there are no security options", func(t *testing.T) { spec := &specs.Spec{} err := daemon.setWindowsCredentialSpec(&container.Container{}, spec) assert.NilError(t, err) assert.Check(t, spec.Windows == nil) err = daemon.setWindowsCredentialSpec(&container.Container{HostConfig: &containertypes.HostConfig{}}, spec) assert.NilError(t, err) assert.Check(t, spec.Windows == nil) err = daemon.setWindowsCredentialSpec(&container.Container{HostConfig: &containertypes.HostConfig{SecurityOpt: []string{}}}, spec) assert.NilError(t, err) assert.Check(t, spec.Windows == nil) }) dummyContainerID := "dummy-container-ID" containerFactory := func(secOpt string) *container.Container { if !strings.Contains(secOpt, "=") { secOpt = "credentialspec=" + secOpt } return &container.Container{ ID: dummyContainerID, HostConfig: &containertypes.HostConfig{ SecurityOpt: []string{secOpt}, }, } } credSpecsDir := filepath.Join(tmpDaemonRoot, credentialSpecFileLocation) dummyCredFileContents := `{"We don't need no": "education"}` t.Run("happy path with a 'file://' option", func(t *testing.T) { spec := &specs.Spec{} // let's render a dummy cred file err := os.Mkdir(credSpecsDir, os.ModePerm) assert.NilError(t, err) dummyCredFileName := "dummy-cred-spec.json" dummyCredFilePath := filepath.Join(credSpecsDir, dummyCredFileName) err = os.WriteFile(dummyCredFilePath, []byte(dummyCredFileContents), 0644) defer func() { assert.NilError(t, os.Remove(dummyCredFilePath)) }() assert.NilError(t, err) err = daemon.setWindowsCredentialSpec(containerFactory("file://"+dummyCredFileName), spec) assert.NilError(t, err) if assert.Check(t, spec.Windows != nil) { assert.Equal(t, dummyCredFileContents, spec.Windows.CredentialSpec) } }) t.Run("it's not allowed to use a 'file://' option with an absolute path", func(t *testing.T) { spec := &specs.Spec{} err := daemon.setWindowsCredentialSpec(containerFactory(`file://C:\path\to\my\credspec.json`), spec) assert.ErrorContains(t, err, "invalid credential spec: file:// path cannot be absolute") assert.Check(t, spec.Windows == nil) }) t.Run("it's not allowed to use a 'file://' option breaking out of the cred specs' directory", func(t *testing.T) { spec := &specs.Spec{} err := daemon.setWindowsCredentialSpec(containerFactory(`file://..\credspec.json`), spec) assert.ErrorContains(t, err, fmt.Sprintf("invalid credential spec: file:// path must be under %s", credSpecsDir)) assert.Check(t, spec.Windows == nil) }) t.Run("when using a 'file://' option pointing to a file that doesn't exist, it fails gracefully", func(t *testing.T) { spec := &specs.Spec{} err := daemon.setWindowsCredentialSpec(containerFactory("file://i-dont-exist.json"), spec) assert.Check(t, is.ErrorContains(err, fmt.Sprintf("failed to load credential spec for container %s", dummyContainerID))) assert.Check(t, is.ErrorIs(err, os.ErrNotExist)) assert.Check(t, spec.Windows == nil) }) t.Run("happy path with a 'registry://' option", func(t *testing.T) { valueName := "my-cred-spec" key := &dummyRegistryKey{ getStringValueFunc: func(name string) (val string, valtype uint32, err error) { assert.Equal(t, valueName, name) return dummyCredFileContents, 0, nil }, } defer setRegistryOpenKeyFunc(t, key)() spec := &specs.Spec{} assert.NilError(t, daemon.setWindowsCredentialSpec(containerFactory("registry://"+valueName), spec)) if assert.Check(t, spec.Windows != nil) { assert.Equal(t, dummyCredFileContents, spec.Windows.CredentialSpec) } assert.Check(t, key.closed) }) t.Run("when using a 'registry://' option and opening the registry key fails, it fails gracefully", func(t *testing.T) { dummyError := fmt.Errorf("dummy error") defer setRegistryOpenKeyFunc(t, &dummyRegistryKey{}, dummyError)() spec := &specs.Spec{} err := daemon.setWindowsCredentialSpec(containerFactory("registry://my-cred-spec"), spec) assert.ErrorContains(t, err, fmt.Sprintf("registry key %s could not be opened: %v", credentialSpecRegistryLocation, dummyError)) assert.Check(t, spec.Windows == nil) }) t.Run("when using a 'registry://' option pointing to a value that doesn't exist, it fails gracefully", func(t *testing.T) { valueName := "my-cred-spec" key := &dummyRegistryKey{ getStringValueFunc: func(name string) (val string, valtype uint32, err error) { assert.Equal(t, valueName, name) return "", 0, registry.ErrNotExist }, } defer setRegistryOpenKeyFunc(t, key)() spec := &specs.Spec{} err := daemon.setWindowsCredentialSpec(containerFactory("registry://"+valueName), spec) assert.ErrorContains(t, err, fmt.Sprintf("registry credential spec %q for container %s was not found", valueName, dummyContainerID)) assert.Check(t, key.closed) }) t.Run("when using a 'registry://' option and reading the registry value fails, it fails gracefully", func(t *testing.T) { dummyError := fmt.Errorf("dummy error") valueName := "my-cred-spec" key := &dummyRegistryKey{ getStringValueFunc: func(name string) (val string, valtype uint32, err error) { assert.Equal(t, valueName, name) return "", 0, dummyError }, } defer setRegistryOpenKeyFunc(t, key)() spec := &specs.Spec{} err := daemon.setWindowsCredentialSpec(containerFactory("registry://"+valueName), spec) assert.ErrorContains(t, err, fmt.Sprintf("error reading credential spec %q from registry for container %s: %v", valueName, dummyContainerID, dummyError)) assert.Check(t, key.closed) }) t.Run("happy path with a 'config://' option", func(t *testing.T) { configID := "my-cred-spec" dependencyManager := swarmagent.NewDependencyManager(nil) dependencyManager.Configs().Add(swarmapi.Config{ ID: configID, Spec: swarmapi.ConfigSpec{ Data: []byte(dummyCredFileContents), }, }) task := &swarmapi.Task{ Spec: swarmapi.TaskSpec{ Runtime: &swarmapi.TaskSpec_Container{ Container: &swarmapi.ContainerSpec{ Configs: []*swarmapi.ConfigReference{ { ConfigID: configID, }, }, }, }, }, } cntr := containerFactory("config://" + configID) cntr.DependencyStore = swarmagent.Restrict(dependencyManager, task) spec := &specs.Spec{} err := daemon.setWindowsCredentialSpec(cntr, spec) assert.NilError(t, err) if assert.Check(t, spec.Windows != nil) { assert.Equal(t, dummyCredFileContents, spec.Windows.CredentialSpec) } }) t.Run("using a 'config://' option on a container not managed by swarmkit is not allowed, and results in a generic error message to hide that purely internal API", func(t *testing.T) { spec := &specs.Spec{} err := daemon.setWindowsCredentialSpec(containerFactory("config://whatever"), spec) assert.Equal(t, errInvalidCredentialSpecSecOpt, err) assert.Check(t, spec.Windows == nil) }) t.Run("happy path with a 'raw://' option", func(t *testing.T) { spec := &specs.Spec{} err := daemon.setWindowsCredentialSpec(containerFactory("raw://"+dummyCredFileContents), spec) assert.NilError(t, err) if assert.Check(t, spec.Windows != nil) { assert.Equal(t, dummyCredFileContents, spec.Windows.CredentialSpec) } }) t.Run("it's not case sensitive in the option names", func(t *testing.T) { spec := &specs.Spec{} err := daemon.setWindowsCredentialSpec(containerFactory("CreDENtiaLSPeC=rAw://"+dummyCredFileContents), spec) assert.NilError(t, err) if assert.Check(t, spec.Windows != nil) { assert.Equal(t, dummyCredFileContents, spec.Windows.CredentialSpec) } }) t.Run("it rejects unknown options", func(t *testing.T) { spec := &specs.Spec{} err := daemon.setWindowsCredentialSpec(containerFactory("credentialspe=config://whatever"), spec) assert.ErrorContains(t, err, "security option not supported: credentialspe") assert.Check(t, spec.Windows == nil) }) t.Run("it rejects unsupported credentialspec options", func(t *testing.T) { spec := &specs.Spec{} err := daemon.setWindowsCredentialSpec(containerFactory("idontexist://whatever"), spec) assert.Equal(t, errInvalidCredentialSpecSecOpt, err) assert.Check(t, spec.Windows == nil) }) for _, option := range []string{"file", "registry", "config", "raw"} { t.Run(fmt.Sprintf("it rejects empty values for %s", option), func(t *testing.T) { spec := &specs.Spec{} err := daemon.setWindowsCredentialSpec(containerFactory(option+"://"), spec) assert.Equal(t, errInvalidCredentialSpecSecOpt, err) assert.Check(t, spec.Windows == nil) }) } } /* Helpers below */ type dummyRegistryKey struct { getStringValueFunc func(name string) (val string, valtype uint32, err error) closed bool } func (k *dummyRegistryKey) GetStringValue(name string) (val string, valtype uint32, err error) { return k.getStringValueFunc(name) } func (k *dummyRegistryKey) Close() error { k.closed = true return nil } // setRegistryOpenKeyFunc replaces the registryOpenKeyFunc package variable, and returns a function // to be called to revert the change when done with testing. func setRegistryOpenKeyFunc(t *testing.T, key *dummyRegistryKey, err ...error) func() { previousRegistryOpenKeyFunc := registryOpenKeyFunc registryOpenKeyFunc = func(baseKey registry.Key, path string, access uint32) (registryKey, error) { // this should always be called with exactly the same arguments assert.Equal(t, registry.LOCAL_MACHINE, baseKey) assert.Equal(t, credentialSpecRegistryLocation, path) assert.Equal(t, uint32(registry.QUERY_VALUE), access) if len(err) > 0 { return nil, err[0] } return key, nil } return func() { registryOpenKeyFunc = previousRegistryOpenKeyFunc } } func TestSetupWindowsDevices(t *testing.T) { t.Run("it does nothing if there are no devices", func(t *testing.T) { devices, err := setupWindowsDevices(nil) assert.NilError(t, err) assert.Equal(t, len(devices), 0) }) t.Run("it fails if any devices are blank", func(t *testing.T) { devices, err := setupWindowsDevices([]containertypes.DeviceMapping{{PathOnHost: "class/anything"}, {PathOnHost: ""}}) assert.ErrorContains(t, err, "invalid device assignment path") assert.ErrorContains(t, err, "''") assert.Equal(t, len(devices), 0) }) t.Run("it fails if all devices do not contain '/' or '://'", func(t *testing.T) { devices, err := setupWindowsDevices([]containertypes.DeviceMapping{{PathOnHost: "anything"}, {PathOnHost: "goes"}}) assert.ErrorContains(t, err, "invalid device assignment path") assert.ErrorContains(t, err, "'anything'") assert.Equal(t, len(devices), 0) }) t.Run("it fails if any devices do not contain '/' or '://'", func(t *testing.T) { devices, err := setupWindowsDevices([]containertypes.DeviceMapping{{PathOnHost: "class/anything"}, {PathOnHost: "goes"}}) assert.ErrorContains(t, err, "invalid device assignment path") assert.ErrorContains(t, err, "'goes'") assert.Equal(t, len(devices), 0) }) t.Run("it fails if all '/'-separated devices do not have IDType 'class'", func(t *testing.T) { devices, err := setupWindowsDevices([]containertypes.DeviceMapping{{PathOnHost: "klass/anything"}, {PathOnHost: "klass/goes"}}) assert.ErrorContains(t, err, "invalid device assignment path") assert.ErrorContains(t, err, "'klass/anything'") assert.Equal(t, len(devices), 0) }) t.Run("it fails if any '/'-separated devices do not have IDType 'class'", func(t *testing.T) { devices, err := setupWindowsDevices([]containertypes.DeviceMapping{{PathOnHost: "class/anything"}, {PathOnHost: "klass/goes"}}) assert.ErrorContains(t, err, "invalid device assignment path") assert.ErrorContains(t, err, "'klass/goes'") assert.Equal(t, len(devices), 0) }) t.Run("it fails if any '://'-separated devices have IDType ''", func(t *testing.T) { devices, err := setupWindowsDevices([]containertypes.DeviceMapping{{PathOnHost: "class/anything"}, {PathOnHost: "://goes"}}) assert.ErrorContains(t, err, "invalid device assignment path") assert.ErrorContains(t, err, "'://goes'") assert.Equal(t, len(devices), 0) }) t.Run("it creates devices if all '/'-separated devices have IDType 'class'", func(t *testing.T) { devices, err := setupWindowsDevices([]containertypes.DeviceMapping{{PathOnHost: "class/anything"}, {PathOnHost: "class/goes"}}) expectedDevices := []specs.WindowsDevice{{IDType: "class", ID: "anything"}, {IDType: "class", ID: "goes"}} assert.NilError(t, err) assert.Equal(t, len(devices), len(expectedDevices)) for i := range expectedDevices { assert.Equal(t, devices[i], expectedDevices[i]) } }) t.Run("it creates devices if all '://'-separated devices have non-blank IDType", func(t *testing.T) { devices, err := setupWindowsDevices([]containertypes.DeviceMapping{{PathOnHost: "class://anything"}, {PathOnHost: "klass://goes"}}) expectedDevices := []specs.WindowsDevice{{IDType: "class", ID: "anything"}, {IDType: "klass", ID: "goes"}} assert.NilError(t, err) assert.Equal(t, len(devices), len(expectedDevices)) for i := range expectedDevices { assert.Equal(t, devices[i], expectedDevices[i]) } }) t.Run("it creates devices when given a mix of '/'-separated and '://'-separated devices", func(t *testing.T) { devices, err := setupWindowsDevices([]containertypes.DeviceMapping{{PathOnHost: "class/anything"}, {PathOnHost: "klass://goes"}}) expectedDevices := []specs.WindowsDevice{{IDType: "class", ID: "anything"}, {IDType: "klass", ID: "goes"}} assert.NilError(t, err) assert.Equal(t, len(devices), len(expectedDevices)) for i := range expectedDevices { assert.Equal(t, devices[i], expectedDevices[i]) } }) }