diff --git a/e2e/config/config.go b/e2e/config/config.go index 54faf7ae32..d714b3482f 100644 --- a/e2e/config/config.go +++ b/e2e/config/config.go @@ -6,6 +6,7 @@ package config import ( + "fmt" "io/ioutil" "os" "testing" @@ -400,6 +401,210 @@ func (c configTests) configGlobal(t *testing.T) { } } +// Tests that require combinations of directives to be set +func (c configTests) configGlobalCombination(t *testing.T) { + e2e.EnsureImage(t, c.env) + + setDirective := func(t *testing.T, directives map[string]string) { + for k, v := range directives { + c.env.RunSingularity( + t, + e2e.WithProfile(e2e.RootProfile), + e2e.WithCommand("config global"), + e2e.WithArgs("--set", k, v), + e2e.ExpectExit(0), + ) + } + } + resetDirective := func(t *testing.T, directives map[string]string) { + for k := range directives { + c.env.RunSingularity( + t, + e2e.WithProfile(e2e.RootProfile), + e2e.WithCommand("config global"), + e2e.WithArgs("--reset", k), + e2e.ExpectExit(0), + ) + } + } + + u := e2e.UserProfile.HostUser(t) + g, err := user.GetGrGID(u.GID) + if err != nil { + t.Fatalf("could not retrieve user group information: %s", err) + } + + tests := []struct { + name string + argv []string + profile e2e.Profile + addRequirementsFn func(*testing.T) + cwd string + directives map[string]string + exit int + resultOp e2e.SingularityCmdResultOp + }{ + { + name: "AllowNetUsersNobody", + argv: []string{"--net", c.env.ImagePath, "true"}, + profile: e2e.UserProfile, + directives: map[string]string{ + "allow net users": "nobody", + }, + exit: 255, + }, + { + name: "AllowNetUsersUser", + argv: []string{"--net", "--network", "bridge", c.env.ImagePath, "true"}, + profile: e2e.UserProfile, + directives: map[string]string{ + "allow net users": u.Name, + }, + exit: 255, + }, + { + name: "AllowNetUsersUID", + argv: []string{"--net", "--network", "bridge", c.env.ImagePath, "true"}, + profile: e2e.UserProfile, + directives: map[string]string{ + "allow net users": fmt.Sprintf("%d", u.UID), + }, + exit: 255, + }, + { + name: "AllowNetUsersUserOK", + argv: []string{"--net", "--network", "bridge", c.env.ImagePath, "true"}, + profile: e2e.UserProfile, + directives: map[string]string{ + "allow net users": u.Name, + "allow net networks": "bridge", + }, + exit: 0, + }, + { + name: "AllowNetUsersUIDOK", + argv: []string{"--net", "--network", "bridge", c.env.ImagePath, "true"}, + profile: e2e.UserProfile, + directives: map[string]string{ + "allow net users": fmt.Sprintf("%d", u.UID), + "allow net networks": "bridge", + }, + exit: 0, + }, + { + name: "AllowNetGroupsNobody", + argv: []string{"--net", "--network", "bridge", c.env.ImagePath, "true"}, + profile: e2e.UserProfile, + directives: map[string]string{ + "allow net groups": "nobody", + }, + exit: 255, + }, + { + name: "AllowNetGroupsGroup", + argv: []string{"--net", "--network", "bridge", c.env.ImagePath, "true"}, + profile: e2e.UserProfile, + directives: map[string]string{ + "allow net groups": g.Name, + }, + exit: 255, + }, + { + name: "AllowNetGroupsGID", + argv: []string{"--net", "--network", "bridge", c.env.ImagePath, "true"}, + profile: e2e.UserProfile, + directives: map[string]string{ + "allow net groups": fmt.Sprintf("%d", g.GID), + }, + exit: 255, + }, + { + name: "AllowNetGroupsGroupOK", + argv: []string{"--net", "--network", "bridge", c.env.ImagePath, "true"}, + profile: e2e.UserProfile, + directives: map[string]string{ + "allow net groups": g.Name, + "allow net networks": "bridge", + }, + exit: 0, + }, + { + name: "AllowNetGroupsGIDOK", + argv: []string{"--net", "--network", "bridge", c.env.ImagePath, "true"}, + profile: e2e.UserProfile, + directives: map[string]string{ + "allow net groups": fmt.Sprintf("%d", g.GID), + "allow net networks": "bridge", + }, + exit: 0, + }, + { + name: "AllowNetNetworksMultiMulti", + // Two networks allowed, asking for both + argv: []string{"--net", "--network", "bridge,ptp", c.env.ImagePath, "true"}, + profile: e2e.UserProfile, + directives: map[string]string{ + "allow net users": u.Name, + "allow net networks": "bridge,ptp", + }, + exit: 0, + }, + { + // Two networks allowed, asking for one + name: "AllowNetNetworksMultiOne", + argv: []string{"--net", "--network", "ptp", c.env.ImagePath, "true"}, + profile: e2e.UserProfile, + directives: map[string]string{ + "allow net users": u.Name, + "allow net networks": "bridge,ptp", + }, + exit: 0, + }, + { + // One network allowed, but asking for two + name: "AllowNetNetworksOneMulti", + argv: []string{"--net", "--network", "bridge,ptp", c.env.ImagePath, "true"}, + profile: e2e.UserProfile, + directives: map[string]string{ + "allow net users": u.Name, + "allow net networks": "bridge", + }, + exit: 255, + }, + { + // No networks allowed, asking for two + name: "AllowNetNetworksNoneMulti", + argv: []string{"--net", "--network", "bridge,ptp", c.env.ImagePath, "true"}, + profile: e2e.UserProfile, + directives: map[string]string{ + "allow net users": u.Name, + }, + exit: 255, + }, + } + + for _, tt := range tests { + c.env.RunSingularity( + t, + e2e.AsSubtest(tt.name), + e2e.WithProfile(tt.profile), + e2e.WithDir(tt.cwd), + e2e.PreRun(func(t *testing.T) { + if tt.addRequirementsFn != nil { + tt.addRequirementsFn(t) + } + setDirective(t, tt.directives) + }), + e2e.PostRun(func(t *testing.T) { + resetDirective(t, tt.directives) + }), + e2e.WithCommand("exec"), + e2e.WithArgs(tt.argv...), + e2e.ExpectExit(tt.exit, tt.resultOp), + ) + } +} + func (c configTests) configFile(t *testing.T) { e2e.EnsureImage(t, c.env) @@ -469,7 +674,8 @@ func E2ETests(env e2e.TestEnv) testhelper.Tests { np := testhelper.NoParallel return testhelper.Tests{ - "config file": c.configFile, // test --config file option - "config global": np(c.configGlobal), // test various global configuration + "config file": c.configFile, // test --config file option + "config global": np(c.configGlobal), // test various global configuration + "config global combination": np(c.configGlobalCombination), // test various global configuration with combination } } diff --git a/internal/app/singularity/cache_clean_linux.go b/internal/app/singularity/cache_clean_linux.go index ab71be997b..3341e99644 100644 --- a/internal/app/singularity/cache_clean_linux.go +++ b/internal/app/singularity/cache_clean_linux.go @@ -11,6 +11,7 @@ import ( "github.com/sylabs/singularity/internal/pkg/cache" "github.com/sylabs/singularity/pkg/sylog" + "github.com/sylabs/singularity/pkg/util/slice" ) var ( @@ -42,7 +43,7 @@ func CleanSingularityCache(imgCache *cache.Handle, dryRun bool, cacheCleanTypes // If specified caches, and we don't have 'all' specified then clean the specified // ones only. - if len(cacheCleanTypes) > 0 && !stringInSlice("all", cacheCleanTypes) { + if len(cacheCleanTypes) > 0 && !slice.ContainsString(cacheCleanTypes, "all") { cachesToClean = cacheCleanTypes } diff --git a/internal/app/singularity/cache_list_linux.go b/internal/app/singularity/cache_list_linux.go index 5dc3e8e5f2..29d67b9405 100644 --- a/internal/app/singularity/cache_list_linux.go +++ b/internal/app/singularity/cache_list_linux.go @@ -14,6 +14,7 @@ import ( "github.com/sylabs/singularity/internal/pkg/cache" "github.com/sylabs/singularity/internal/pkg/util/fs" + "github.com/sylabs/singularity/pkg/util/slice" ) // listTypeCache will list a cache type with given name (cacheType). The options are 'library', and 'oci'. @@ -74,7 +75,7 @@ func ListSingularityCache(imgCache *cache.Handle, cacheListTypes []string, cache blobsShown := false // If types requested includes "all" then we don't want to filter anything - if stringInSlice("all", cacheListTypes) { + if slice.ContainsString(cacheListTypes, "all") { cacheListTypes = []string{} } @@ -82,7 +83,7 @@ func ListSingularityCache(imgCache *cache.Handle, cacheListTypes []string, cache // the type blob is special: 1. there's a // separate counter for it; 2. the cache entries // are actually one level deeper - if len(cacheListTypes) > 0 && !stringInSlice(cacheType, cacheListTypes) { + if len(cacheListTypes) > 0 && !slice.ContainsString(cacheListTypes, cacheType) { continue } cacheDir, err := imgCache.GetOciCacheDir(cacheType) @@ -101,7 +102,7 @@ func ListSingularityCache(imgCache *cache.Handle, cacheListTypes []string, cache blobsShown = true } for _, cacheType := range cache.FileCacheTypes { - if len(cacheListTypes) > 0 && !stringInSlice(cacheType, cacheListTypes) { + if len(cacheListTypes) > 0 && !slice.ContainsString(cacheListTypes, cacheType) { continue } cacheDir, err := imgCache.GetFileCacheDir(cacheType) @@ -141,12 +142,3 @@ func ListSingularityCache(imgCache *cache.Handle, cacheListTypes []string, cache return nil } - -func stringInSlice(a string, list []string) bool { - for _, b := range list { - if b == a { - return true - } - } - return false -} diff --git a/internal/pkg/runtime/engine/singularity/cleanup_linux.go b/internal/pkg/runtime/engine/singularity/cleanup_linux.go index 842c741ec6..fad7ee60a0 100644 --- a/internal/pkg/runtime/engine/singularity/cleanup_linux.go +++ b/internal/pkg/runtime/engine/singularity/cleanup_linux.go @@ -72,13 +72,18 @@ func (e *EngineOperations) CleanupContainer(ctx context.Context, fatal error, st } if networkSetup != nil { - if e.EngineConfig.GetFakeroot() { + net := e.EngineConfig.GetNetwork() + privileged := false + // If a CNI configuration was allowed as non-root (or fakeroot) + if net != "none" && os.Geteuid() != 0 { priv.Escalate() + privileged = true } + sylog.Debugf("Cleaning up CNI network config %s", net) if err := networkSetup.DelNetworks(ctx); err != nil { sylog.Errorf("could not delete networks: %v", err) } - if e.EngineConfig.GetFakeroot() { + if privileged { priv.Drop() } } diff --git a/internal/pkg/runtime/engine/singularity/container_linux.go b/internal/pkg/runtime/engine/singularity/container_linux.go index 18aa121c1d..5049d1206e 100644 --- a/internal/pkg/runtime/engine/singularity/container_linux.go +++ b/internal/pkg/runtime/engine/singularity/container_linux.go @@ -40,6 +40,7 @@ import ( "github.com/sylabs/singularity/pkg/util/loop" "github.com/sylabs/singularity/pkg/util/namespaces" "github.com/sylabs/singularity/pkg/util/singularityconf" + "github.com/sylabs/singularity/pkg/util/slice" "golang.org/x/crypto/ssh/terminal" "golang.org/x/sys/unix" ) @@ -2245,11 +2246,35 @@ func (c *container) prepareNetworkSetup(system *mount.System, pid int) (func(con fakeroot := c.engine.EngineConfig.GetFakeroot() net := c.engine.EngineConfig.GetNetwork() euid := os.Geteuid() + allowedNetUnpriv := false + if euid != 0 { + // Is the user permitted in the list of unpriv users / groups permitted to use CNI? + allowedNetUser, err := user.UIDInList(euid, c.engine.EngineConfig.File.AllowNetUsers) + if err != nil { + return nil, err + } + allowedNetGroup, err := user.UIDInAnyGroup(euid, c.engine.EngineConfig.File.AllowNetGroups) + if err != nil { + return nil, err + } + // Is/are the requested network(s) in the list of networks allowed for unpriv CNI? + allowedNetNetwork := false + for _, n := range strings.Split(net, ",") { + allowedNetNetwork = slice.ContainsString(c.engine.EngineConfig.File.AllowNetNetworks, n) + // If any one requested network is not allowed, disallow the whole config + if !allowedNetNetwork { + sylog.Errorf("Network %s is not permitted for unprivileged users.", n) + break + } + } + // User is in the user / groups allowed, and requesting an allowed network? + allowedNetUnpriv = (allowedNetUser || allowedNetGroup) && allowedNetNetwork + } if !c.netNS || net == noneNet { return nil, nil - } else if (c.userNS || euid != 0) && !fakeroot { - return nil, fmt.Errorf("network requires root or --fakeroot, users need to specify --network=%s with --net", noneNet) + } else if (c.userNS || euid != 0) && !fakeroot && !allowedNetUnpriv { + return nil, fmt.Errorf("network requires root or --fakeroot, non-root users can only use --network=%s unless permitted by the administrator", noneNet) } // we hold a reference to container network namespace @@ -2264,6 +2289,7 @@ func (c *container) prepareNetworkSetup(system *mount.System, pid int) (func(con } networks := strings.Split(c.engine.EngineConfig.GetNetwork(), ",") + // In fakeroot mode only permit the `fakeroot` CNI config if fakeroot && euid != 0 && net != fakerootNet { // set as debug message to avoid annoying warning sylog.Debugf("only '%s' network is allowed for regular user, you requested '%s'", fakerootNet, net) @@ -2293,10 +2319,12 @@ func (c *container) prepareNetworkSetup(system *mount.System, pid int) (func(con } return func(ctx context.Context) error { - if fakeroot { + if fakeroot || allowedNetUnpriv { // prevent port hijacking between user processes - if err := networkSetup.SetPortProtection(fakerootNet, 0); err != nil { - return err + for _, n := range strings.Split(net, ",") { + if err := networkSetup.SetPortProtection(n, 0); err != nil { + return err + } } if euid != 0 { priv.Escalate() diff --git a/internal/pkg/util/user/membership.go b/internal/pkg/util/user/membership.go new file mode 100644 index 0000000000..e1d382257a --- /dev/null +++ b/internal/pkg/util/user/membership.go @@ -0,0 +1,51 @@ +// Copyright (c) 2021, Sylabs Inc. All rights reserved. +// This software is licensed under a 3-clause BSD license. Please consult the +// LICENSE.md file distributed with the sources of this project regarding your +// rights to use or distribute this software. + +package user + +import ( + "os/user" + "strconv" + + "github.com/sylabs/singularity/pkg/sylog" + "github.com/sylabs/singularity/pkg/util/slice" +) + +// UIDInList returns true if the user with supplied uid is in list (match by uid or username). +// List is a string slice that may contain UIDs, usernames, or both. +func UIDInList(uid int, list []string) (bool, error) { + uidStr := strconv.Itoa(uid) + u, err := lookupUnixUid(uid) + if err != nil { + return false, err + } + return slice.ContainsAnyString(list, []string{uidStr, u.Name}), nil +} + +// UIDInAnyGroup returns true if the user with supplied uid is a member of any group in list. +// List is a string slice that may contain GIDs, groupnames, or both. +func UIDInAnyGroup(uid int, list []string) (bool, error) { + uidStr := strconv.Itoa(uid) + u, err := user.LookupId(uidStr) + if err != nil { + return false, err + } + // Get the numeric GIDs + userGroups, err := u.GroupIds() + if err != nil { + return false, err + } + // Append the group names + for _, g := range userGroups { + gname, err := user.LookupGroupId(g) + if err != nil { + sylog.Warningf("while looking up gid %s: %v", g, err) + continue + } + userGroups = append(userGroups, gname.Name) + } + // Match on the GIDs or group names + return slice.ContainsAnyString(list, userGroups), nil +} diff --git a/internal/pkg/util/user/membership_test.go b/internal/pkg/util/user/membership_test.go new file mode 100644 index 0000000000..4b2c5a2e1d --- /dev/null +++ b/internal/pkg/util/user/membership_test.go @@ -0,0 +1,114 @@ +// Copyright (c) 2021, Sylabs Inc. All rights reserved. +// This software is licensed under a 3-clause BSD license. Please consult the +// LICENSE.md file distributed with the sources of this project regarding your +// rights to use or distribute this software. + +package user + +import ( + "fmt" + "strconv" + "testing" +) + +func TestUserInList(t *testing.T) { + u, err := Current() + if err != nil { + t.Fatalf("Could not identify current user for test: %v", err) + } + + type args struct { + uid int + list []string + } + tests := []struct { + name string + args args + want bool + wantErr bool + }{ + { + name: "NotInList", + args: args{int(u.UID), []string{"9999", "notauser"}}, + want: false, + wantErr: false, + }, + { + name: "InListUid", + args: args{int(u.UID), []string{"9999", "notauser", strconv.Itoa(int(u.UID))}}, + want: true, + wantErr: false, + }, + { + name: "InListName", + args: args{int(u.UID), []string{"9999", "notauser", u.Name}}, + want: true, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := UIDInList(tt.args.uid, tt.args.list) + if (err != nil) != tt.wantErr { + t.Errorf("UIDInList() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("UIDInList() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestUserInGroup(t *testing.T) { + u, err := current() + if err != nil { + t.Fatalf("Could not identify current user for test: %v", err) + } + g, err := currentGroup() + if err != nil { + t.Fatalf("Could not identify current group for test: %v", err) + } + + type args struct { + uid int + list []string + } + tests := []struct { + name string + args args + want bool + wantErr bool + }{ + { + name: "NotInList", + args: args{int(u.UID), []string{"9999", "notagroup"}}, + want: false, + wantErr: false, + }, + { + name: "InListUid", + args: args{int(u.UID), []string{"9999", "notagroup", fmt.Sprintf("%d", g.GID)}}, + want: true, + wantErr: false, + }, + { + name: "InListName", + args: args{int(u.UID), []string{"9999", "notagroup", g.Name}}, + want: true, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := UIDInAnyGroup(tt.args.uid, tt.args.list) + if (err != nil) != tt.wantErr { + t.Errorf("UIDInAnyGroup() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("UIDInAnyGroup() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/util/singularityconf/config.go b/pkg/util/singularityconf/config.go index 8a64c5134c..395b725aeb 100644 --- a/pkg/util/singularityconf/config.go +++ b/pkg/util/singularityconf/config.go @@ -52,6 +52,9 @@ type File struct { LimitContainerOwners []string `directive:"limit container owners"` LimitContainerGroups []string `directive:"limit container groups"` LimitContainerPaths []string `directive:"limit container paths"` + AllowNetUsers []string `directive:"allow net users"` + AllowNetGroups []string `directive:"allow net groups"` + AllowNetNetworks []string `directive:"allow net networks"` RootDefaultCapabilities string `default:"full" authorized:"full,file,no" directive:"root default capabilities"` MemoryFSType string `default:"tmpfs" authorized:"tmpfs,ramfs" directive:"memory fs type"` CniConfPath string `directive:"cni configuration path"` @@ -262,6 +265,40 @@ allow container extfs = {{ if eq .AllowContainerExtfs true }}yes{{ else }}no{{ e allow container dir = {{ if eq .AllowContainerDir true }}yes{{ else }}no{{ end }} allow container encrypted = {{ if eq .AllowContainerEncrypted true }}yes{{ else }}no{{ end }} +# ALLOW NET USERS: [STRING] +# DEFAULT: NULL +# Allow specified root administered CNI network configurations to be used by the +# specified list of users. By default only root may use CNI configuration, +# except in the case of a fakeroot execution where only 40_fakeroot.conflist +# is used. This feature only applies when Singularity is running in +# SUID mode and the user is non-root. +#allow net users = gmk, singularity +{{ range $index, $owner := .AllowNetUsers }} +{{- if eq $index 0 }}allow net users = {{ else }}, {{ end }}{{$owner}} +{{- end }} + +# ALLOW NET GROUPS: [STRING] +# DEFAULT: NULL +# Allow specified root administered CNI network configurations to be used by the +# specified list of users. By default only root may use CNI configuration, +# except in the case of a fakeroot execution where only 40_fakeroot.conflist +# is used. This feature only applies when Singularity is running in +# SUID mode and the user is non-root. +#allow net groups = group1, singularity +{{ range $index, $group := .AllowNetGroups }} +{{- if eq $index 0 }}allow net groups = {{ else }}, {{ end }}{{$group}} +{{- end }} + +# ALLOW NET NETWORKS: [STRING] +# DEFAULT: NULL +# Specify the names of CNI network configurations that may be used by users and +# groups listed in the allow net users / allow net groups directives. Thus feature +# only applies when Singularity is running in SUID mode and the user is non-root. +#allow net networks = bridge +{{ range $index, $group := .AllowNetNetworks }} +{{- if eq $index 0 }}allow net networks = {{ else }}, {{ end }}{{$group}} +{{- end }} + # ALWAYS USE NV ${TYPE}: [BOOL] # DEFAULT: no # This feature allows an administrator to determine that every action command diff --git a/pkg/util/slice/slice.go b/pkg/util/slice/slice.go new file mode 100644 index 0000000000..95581509ba --- /dev/null +++ b/pkg/util/slice/slice.go @@ -0,0 +1,28 @@ +// Copyright (c) 2021, Sylabs Inc. All rights reserved. +// This software is licensed under a 3-clause BSD license. Please consult the +// LICENSE.md file distributed with the sources of this project regarding your +// rights to use or distribute this software. + +package slice + +// ContainsString returns true if string slice s contains match +func ContainsString(s []string, match string) bool { + for _, a := range s { + if a == match { + return true + } + } + return false +} + +// ContainsAnyString returns true if string slice s contains any of matches +func ContainsAnyString(s []string, matches []string) bool { + for _, m := range matches { + for _, a := range s { + if a == m { + return true + } + } + } + return false +} diff --git a/pkg/util/slice/slice_test.go b/pkg/util/slice/slice_test.go new file mode 100644 index 0000000000..00e8281938 --- /dev/null +++ b/pkg/util/slice/slice_test.go @@ -0,0 +1,133 @@ +// Copyright (c) 2021, Sylabs Inc. All rights reserved. +// This software is licensed under a 3-clause BSD license. Please consult the +// LICENSE.md file distributed with the sources of this project regarding your +// rights to use or distribute this software. + +package slice + +import "testing" + +func TestContainsString(t *testing.T) { + type args struct { + s []string + match string + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "NoMatchSingle", + args: args{[]string{"a"}, "1"}, + want: false, + }, + { + name: "NoMatchMulti", + args: args{[]string{"a", "b", "c"}, "1"}, + want: false, + }, + { + name: "NoMatchEmpty", + args: args{[]string{}, "1"}, + want: false, + }, + { + name: "MatchSingle", + args: args{[]string{"a"}, "a"}, + want: true, + }, + { + name: "MatchMulti", + args: args{[]string{"a", "b", "c"}, "a"}, + want: true, + }, + { + name: "EmptyMatch", + args: args{[]string{"a", "b", "c"}, ""}, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ContainsString(tt.args.s, tt.args.match); got != tt.want { + t.Errorf("ContainsString() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestContainsAnyString(t *testing.T) { + type args struct { + s []string + matches []string + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "NoMatchSingle", + args: args{[]string{"a"}, []string{"1"}}, + want: false, + }, + { + name: "NoMatchMulti", + args: args{[]string{"a", "b", "c"}, []string{"1"}}, + want: false, + }, + { + name: "NoMatchEmpty", + args: args{[]string{}, []string{"1"}}, + want: false, + }, + { + name: "NoMatchesSingle", + args: args{[]string{}, []string{"1", "2", "3"}}, + want: false, + }, + { + name: "NoMatchesMulti", + args: args{[]string{}, []string{"1", "2", "3"}}, + want: false, + }, + { + name: "NoMatchesEmpty", + args: args{[]string{}, []string{"1", "2", "3"}}, + want: false, + }, + { + name: "MatchSingle", + args: args{[]string{"a"}, []string{"a"}}, + want: true, + }, + { + name: "MatchMulti", + args: args{[]string{"a", "b", "c"}, []string{"a"}}, + want: true, + }, + { + name: "MatchesSingle", + args: args{[]string{"a"}, []string{"1", "a", "b"}}, + want: true, + }, + { + name: "MatchesMulti", + args: args{[]string{"a", "b", "c"}, []string{"1", "a", "b"}}, + want: true, + }, + { + name: "EmptyMatch", + args: args{[]string{"a", "b", "c"}, []string{""}}, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ContainsAnyString(tt.args.s, tt.args.matches); got != tt.want { + t.Errorf("ContainsString() = %v, want %v", got, tt.want) + } + }) + } +}