Windows: Add named pipe mount support
Current insider builds of Windows have support for mounting individual named pipe servers from the host to the guest. This allows, for example, exposing the docker engine's named pipe to a container. This change allows the user to request such a mount via the normal bind mount syntax in the CLI: docker run -v \\.\pipe\docker_engine:\\.\pipe\docker_engine <args> Signed-off-by: John Starks <jostarks@microsoft.com>
This commit is contained in:
parent
6f1907898f
commit
54354db850
10 changed files with 209 additions and 62 deletions
|
@ -15,6 +15,8 @@ const (
|
|||
TypeVolume Type = "volume"
|
||||
// TypeTmpfs is the type for mounting tmpfs
|
||||
TypeTmpfs Type = "tmpfs"
|
||||
// TypeNamedPipe is the type for mounting Windows named pipes
|
||||
TypeNamedPipe Type = "npipe"
|
||||
)
|
||||
|
||||
// Mount represents a mount (volume).
|
||||
|
|
71
integration-cli/docker_api_containers_windows_test.go
Normal file
71
integration-cli/docker_api_containers_windows_test.go
Normal file
|
@ -0,0 +1,71 @@
|
|||
// +build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
winio "github.com/Microsoft/go-winio"
|
||||
"github.com/docker/docker/integration-cli/checker"
|
||||
"github.com/docker/docker/integration-cli/request"
|
||||
"github.com/go-check/check"
|
||||
)
|
||||
|
||||
func (s *DockerSuite) TestContainersAPICreateMountsBindNamedPipe(c *check.C) {
|
||||
testRequires(c, SameHostDaemon, DaemonIsWindowsAtLeastBuild(16210)) // Named pipe support was added in RS3
|
||||
|
||||
// Create a host pipe to map into the container
|
||||
hostPipeName := fmt.Sprintf(`\\.\pipe\docker-cli-test-pipe-%x`, rand.Uint64())
|
||||
pc := &winio.PipeConfig{
|
||||
SecurityDescriptor: "D:P(A;;GA;;;AU)", // Allow all users access to the pipe
|
||||
}
|
||||
l, err := winio.ListenPipe(hostPipeName, pc)
|
||||
if err != nil {
|
||||
c.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
// Asynchronously read data that the container writes to the mapped pipe.
|
||||
var b []byte
|
||||
ch := make(chan error)
|
||||
go func() {
|
||||
conn, err := l.Accept()
|
||||
if err == nil {
|
||||
b, err = ioutil.ReadAll(conn)
|
||||
conn.Close()
|
||||
}
|
||||
ch <- err
|
||||
}()
|
||||
|
||||
containerPipeName := `\\.\pipe\docker-cli-test-pipe`
|
||||
text := "hello from a pipe"
|
||||
cmd := fmt.Sprintf("echo %s > %s", text, containerPipeName)
|
||||
|
||||
name := "test-bind-npipe"
|
||||
data := map[string]interface{}{
|
||||
"Image": testEnv.MinimalBaseImage(),
|
||||
"Cmd": []string{"cmd", "/c", cmd},
|
||||
"HostConfig": map[string]interface{}{"Mounts": []map[string]interface{}{{"Type": "npipe", "Source": hostPipeName, "Target": containerPipeName}}},
|
||||
}
|
||||
|
||||
status, resp, err := request.SockRequest("POST", "/containers/create?name="+name, data, daemonHost())
|
||||
c.Assert(err, checker.IsNil, check.Commentf(string(resp)))
|
||||
c.Assert(status, checker.Equals, http.StatusCreated, check.Commentf(string(resp)))
|
||||
|
||||
status, _, err = request.SockRequest("POST", "/containers/"+name+"/start", nil, daemonHost())
|
||||
c.Assert(err, checker.IsNil)
|
||||
c.Assert(status, checker.Equals, http.StatusNoContent)
|
||||
|
||||
err = <-ch
|
||||
if err != nil {
|
||||
c.Fatal(err)
|
||||
}
|
||||
result := strings.TrimSpace(string(b))
|
||||
if result != text {
|
||||
c.Errorf("expected pipe to contain %s, got %s", text, result)
|
||||
}
|
||||
}
|
|
@ -4610,10 +4610,7 @@ func (s *DockerSuite) TestRunAddDeviceCgroupRule(c *check.C) {
|
|||
|
||||
// Verifies that running as local system is operating correctly on Windows
|
||||
func (s *DockerSuite) TestWindowsRunAsSystem(c *check.C) {
|
||||
testRequires(c, DaemonIsWindows)
|
||||
if testEnv.DaemonKernelVersionNumeric() < 15000 {
|
||||
c.Skip("Requires build 15000 or later")
|
||||
}
|
||||
testRequires(c, DaemonIsWindowsAtLeastBuild(15000))
|
||||
out, _ := dockerCmd(c, "run", "--net=none", `--user=nt authority\system`, "--hostname=XYZZY", minimalBaseImage(), "cmd", "/c", `@echo %USERNAME%`)
|
||||
c.Assert(strings.TrimSpace(out), checker.Equals, "XYZZY$")
|
||||
}
|
||||
|
|
|
@ -37,6 +37,12 @@ func DaemonIsWindows() bool {
|
|||
return PlatformIs("windows")
|
||||
}
|
||||
|
||||
func DaemonIsWindowsAtLeastBuild(buildNumber int) func() bool {
|
||||
return func() bool {
|
||||
return DaemonIsWindows() && testEnv.DaemonKernelVersionNumeric() >= buildNumber
|
||||
}
|
||||
}
|
||||
|
||||
func DaemonIsLinux() bool {
|
||||
return PlatformIs("linux")
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ import (
|
|||
|
||||
"github.com/Microsoft/hcsshim"
|
||||
"github.com/docker/docker/pkg/sysinfo"
|
||||
"github.com/docker/docker/pkg/system"
|
||||
opengcs "github.com/jhowardmsft/opengcs/gogcs/client"
|
||||
specs "github.com/opencontainers/runtime-spec/specs-go"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
@ -230,20 +231,35 @@ func (clnt *client) createWindows(containerID string, checkpoint string, checkpo
|
|||
}
|
||||
|
||||
// Add the mounts (volumes, bind mounts etc) to the structure
|
||||
mds := make([]hcsshim.MappedDir, len(spec.Mounts))
|
||||
for i, mount := range spec.Mounts {
|
||||
mds[i] = hcsshim.MappedDir{
|
||||
HostPath: mount.Source,
|
||||
ContainerPath: mount.Destination,
|
||||
ReadOnly: false,
|
||||
}
|
||||
for _, o := range mount.Options {
|
||||
if strings.ToLower(o) == "ro" {
|
||||
mds[i].ReadOnly = true
|
||||
var mds []hcsshim.MappedDir
|
||||
var mps []hcsshim.MappedPipe
|
||||
for _, mount := range spec.Mounts {
|
||||
const pipePrefix = `\\.\pipe\`
|
||||
if strings.HasPrefix(mount.Destination, pipePrefix) {
|
||||
mp := hcsshim.MappedPipe{
|
||||
HostPath: mount.Source,
|
||||
ContainerPipeName: mount.Destination[len(pipePrefix):],
|
||||
}
|
||||
mps = append(mps, mp)
|
||||
} else {
|
||||
md := hcsshim.MappedDir{
|
||||
HostPath: mount.Source,
|
||||
ContainerPath: mount.Destination,
|
||||
ReadOnly: false,
|
||||
}
|
||||
for _, o := range mount.Options {
|
||||
if strings.ToLower(o) == "ro" {
|
||||
md.ReadOnly = true
|
||||
}
|
||||
}
|
||||
mds = append(mds, md)
|
||||
}
|
||||
}
|
||||
configuration.MappedDirectories = mds
|
||||
if len(mps) > 0 && system.GetOSVersion().Build < 16210 { // replace with Win10 RS3 build number at RTM
|
||||
return errors.New("named pipe mounts are not supported on this version of Windows")
|
||||
}
|
||||
configuration.MappedPipes = mps
|
||||
|
||||
hcsContainer, err := hcsshim.CreateContainer(containerID, configuration)
|
||||
if err != nil {
|
||||
|
|
|
@ -4,7 +4,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/docker/docker/api/types/mount"
|
||||
)
|
||||
|
@ -12,8 +12,7 @@ import (
|
|||
var errBindNotExist = errors.New("bind source path does not exist")
|
||||
|
||||
type validateOpts struct {
|
||||
skipBindSourceCheck bool
|
||||
skipAbsolutePathCheck bool
|
||||
skipBindSourceCheck bool
|
||||
}
|
||||
|
||||
func validateMountConfig(mnt *mount.Mount, options ...func(*validateOpts)) error {
|
||||
|
@ -30,10 +29,8 @@ func validateMountConfig(mnt *mount.Mount, options ...func(*validateOpts)) error
|
|||
return &errMountConfig{mnt, err}
|
||||
}
|
||||
|
||||
if !opts.skipAbsolutePathCheck {
|
||||
if err := validateAbsolute(mnt.Target); err != nil {
|
||||
return &errMountConfig{mnt, err}
|
||||
}
|
||||
if err := validateAbsolute(mnt.Target); err != nil {
|
||||
return &errMountConfig{mnt, err}
|
||||
}
|
||||
|
||||
switch mnt.Type {
|
||||
|
@ -97,6 +94,31 @@ func validateMountConfig(mnt *mount.Mount, options ...func(*validateOpts)) error
|
|||
if _, err := ConvertTmpfsOptions(mnt.TmpfsOptions, mnt.ReadOnly); err != nil {
|
||||
return &errMountConfig{mnt, err}
|
||||
}
|
||||
case mount.TypeNamedPipe:
|
||||
if runtime.GOOS != "windows" {
|
||||
return &errMountConfig{mnt, errors.New("named pipe bind mounts are not supported on this OS")}
|
||||
}
|
||||
|
||||
if len(mnt.Source) == 0 {
|
||||
return &errMountConfig{mnt, errMissingField("Source")}
|
||||
}
|
||||
|
||||
if mnt.BindOptions != nil {
|
||||
return &errMountConfig{mnt, errExtraField("BindOptions")}
|
||||
}
|
||||
|
||||
if mnt.ReadOnly {
|
||||
return &errMountConfig{mnt, errExtraField("ReadOnly")}
|
||||
}
|
||||
|
||||
if detectMountType(mnt.Source) != mount.TypeNamedPipe {
|
||||
return &errMountConfig{mnt, fmt.Errorf("'%s' is not a valid pipe path", mnt.Source)}
|
||||
}
|
||||
|
||||
if detectMountType(mnt.Target) != mount.TypeNamedPipe {
|
||||
return &errMountConfig{mnt, fmt.Errorf("'%s' is not a valid pipe path", mnt.Target)}
|
||||
}
|
||||
|
||||
default:
|
||||
return &errMountConfig{mnt, errors.New("mount type unknown")}
|
||||
}
|
||||
|
@ -121,7 +143,7 @@ func errMissingField(name string) error {
|
|||
|
||||
func validateAbsolute(p string) error {
|
||||
p = convertSlash(p)
|
||||
if filepath.IsAbs(p) {
|
||||
if isAbsPath(p) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("invalid mount path: '%s' mount path must be absolute", p)
|
||||
|
|
|
@ -3,7 +3,6 @@ package volume
|
|||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
@ -284,12 +283,7 @@ func ParseMountRaw(raw, volumeDriver string) (*MountPoint, error) {
|
|||
return nil, errInvalidMode(mode)
|
||||
}
|
||||
|
||||
if filepath.IsAbs(spec.Source) {
|
||||
spec.Type = mounttypes.TypeBind
|
||||
} else {
|
||||
spec.Type = mounttypes.TypeVolume
|
||||
}
|
||||
|
||||
spec.Type = detectMountType(spec.Source)
|
||||
spec.ReadOnly = !ReadWrite(mode)
|
||||
|
||||
// cannot assume that if a volume driver is passed in that we should set it
|
||||
|
@ -350,7 +344,7 @@ func ParseMountSpec(cfg mounttypes.Mount, options ...func(*validateOpts)) (*Moun
|
|||
mp.CopyData = false
|
||||
}
|
||||
}
|
||||
case mounttypes.TypeBind:
|
||||
case mounttypes.TypeBind, mounttypes.TypeNamedPipe:
|
||||
mp.Source = clean(convertSlash(cfg.Source))
|
||||
if cfg.BindOptions != nil && len(cfg.BindOptions.Propagation) > 0 {
|
||||
mp.Propagation = cfg.BindOptions.Propagation
|
||||
|
|
|
@ -143,6 +143,7 @@ func TestParseMountRaw(t *testing.T) {
|
|||
type testParseMountRaw struct {
|
||||
bind string
|
||||
driver string
|
||||
expType mount.Type
|
||||
expDest string
|
||||
expSource string
|
||||
expName string
|
||||
|
@ -155,28 +156,31 @@ func TestParseMountRawSplit(t *testing.T) {
|
|||
var cases []testParseMountRaw
|
||||
if runtime.GOOS == "windows" {
|
||||
cases = []testParseMountRaw{
|
||||
{`c:\:d:`, "local", `d:`, `c:\`, ``, "", true, false},
|
||||
{`c:\:d:\`, "local", `d:\`, `c:\`, ``, "", true, false},
|
||||
{`c:\:d:\:ro`, "local", `d:\`, `c:\`, ``, "", false, false},
|
||||
{`c:\:d:\:rw`, "local", `d:\`, `c:\`, ``, "", true, false},
|
||||
{`c:\:d:\:foo`, "local", `d:\`, `c:\`, ``, "", false, true},
|
||||
{`name:d::rw`, "local", `d:`, ``, `name`, "local", true, false},
|
||||
{`name:d:`, "local", `d:`, ``, `name`, "local", true, false},
|
||||
{`name:d::ro`, "local", `d:`, ``, `name`, "local", false, false},
|
||||
{`name:c:`, "", ``, ``, ``, "", true, true},
|
||||
{`driver/name:c:`, "", ``, ``, ``, "", true, true},
|
||||
{`c:\:d:`, "local", mount.TypeBind, `d:`, `c:\`, ``, "", true, false},
|
||||
{`c:\:d:\`, "local", mount.TypeBind, `d:\`, `c:\`, ``, "", true, false},
|
||||
{`c:\:d:\:ro`, "local", mount.TypeBind, `d:\`, `c:\`, ``, "", false, false},
|
||||
{`c:\:d:\:rw`, "local", mount.TypeBind, `d:\`, `c:\`, ``, "", true, false},
|
||||
{`c:\:d:\:foo`, "local", mount.TypeBind, `d:\`, `c:\`, ``, "", false, true},
|
||||
{`\\.\pipe\foo:\\.\pipe\bar`, "local", mount.TypeNamedPipe, `\\.\pipe\bar`, `\\.\pipe\foo`, "", "", true, false},
|
||||
{`\\.\pipe\foo:c:\foo\bar`, "local", mount.TypeNamedPipe, ``, ``, "", "", true, true},
|
||||
{`c:\foo\bar:\\.\pipe\foo`, "local", mount.TypeNamedPipe, ``, ``, "", "", true, true},
|
||||
{`name:d::rw`, "local", mount.TypeVolume, `d:`, ``, `name`, "local", true, false},
|
||||
{`name:d:`, "local", mount.TypeVolume, `d:`, ``, `name`, "local", true, false},
|
||||
{`name:d::ro`, "local", mount.TypeVolume, `d:`, ``, `name`, "local", false, false},
|
||||
{`name:c:`, "", mount.TypeVolume, ``, ``, ``, "", true, true},
|
||||
{`driver/name:c:`, "", mount.TypeVolume, ``, ``, ``, "", true, true},
|
||||
}
|
||||
} else {
|
||||
cases = []testParseMountRaw{
|
||||
{"/tmp:/tmp1", "", "/tmp1", "/tmp", "", "", true, false},
|
||||
{"/tmp:/tmp2:ro", "", "/tmp2", "/tmp", "", "", false, false},
|
||||
{"/tmp:/tmp3:rw", "", "/tmp3", "/tmp", "", "", true, false},
|
||||
{"/tmp:/tmp4:foo", "", "", "", "", "", false, true},
|
||||
{"name:/named1", "", "/named1", "", "name", "", true, false},
|
||||
{"name:/named2", "external", "/named2", "", "name", "external", true, false},
|
||||
{"name:/named3:ro", "local", "/named3", "", "name", "local", false, false},
|
||||
{"local/name:/tmp:rw", "", "/tmp", "", "local/name", "", true, false},
|
||||
{"/tmp:tmp", "", "", "", "", "", true, true},
|
||||
{"/tmp:/tmp1", "", mount.TypeBind, "/tmp1", "/tmp", "", "", true, false},
|
||||
{"/tmp:/tmp2:ro", "", mount.TypeBind, "/tmp2", "/tmp", "", "", false, false},
|
||||
{"/tmp:/tmp3:rw", "", mount.TypeBind, "/tmp3", "/tmp", "", "", true, false},
|
||||
{"/tmp:/tmp4:foo", "", mount.TypeBind, "", "", "", "", false, true},
|
||||
{"name:/named1", "", mount.TypeVolume, "/named1", "", "name", "", true, false},
|
||||
{"name:/named2", "external", mount.TypeVolume, "/named2", "", "name", "external", true, false},
|
||||
{"name:/named3:ro", "local", mount.TypeVolume, "/named3", "", "name", "local", false, false},
|
||||
{"local/name:/tmp:rw", "", mount.TypeVolume, "/tmp", "", "local/name", "", true, false},
|
||||
{"/tmp:tmp", "", mount.TypeBind, "", "", "", "", true, true},
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -195,8 +199,12 @@ func TestParseMountRawSplit(t *testing.T) {
|
|||
continue
|
||||
}
|
||||
|
||||
if m.Type != c.expType {
|
||||
t.Fatalf("Expected type '%s', was '%s', for spec '%s'", c.expType, m.Type, c.bind)
|
||||
}
|
||||
|
||||
if m.Destination != c.expDest {
|
||||
t.Fatalf("Expected destination '%s, was %s', for spec '%s'", c.expDest, m.Destination, c.bind)
|
||||
t.Fatalf("Expected destination '%s', was '%s', for spec '%s'", c.expDest, m.Destination, c.bind)
|
||||
}
|
||||
|
||||
if m.Source != c.expSource {
|
||||
|
|
|
@ -124,7 +124,12 @@ func validateCopyMode(mode bool) error {
|
|||
}
|
||||
|
||||
func convertSlash(p string) string {
|
||||
return filepath.ToSlash(p)
|
||||
return p
|
||||
}
|
||||
|
||||
// isAbsPath reports whether the path is absolute.
|
||||
func isAbsPath(p string) bool {
|
||||
return filepath.IsAbs(p)
|
||||
}
|
||||
|
||||
func splitRawSpec(raw string) ([]string, error) {
|
||||
|
@ -139,6 +144,13 @@ func splitRawSpec(raw string) ([]string, error) {
|
|||
return arr, nil
|
||||
}
|
||||
|
||||
func detectMountType(p string) mounttypes.Type {
|
||||
if filepath.IsAbs(p) {
|
||||
return mounttypes.TypeBind
|
||||
}
|
||||
return mounttypes.TypeVolume
|
||||
}
|
||||
|
||||
func clean(p string) string {
|
||||
return filepath.Clean(p)
|
||||
}
|
||||
|
|
|
@ -6,6 +6,8 @@ import (
|
|||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
mounttypes "github.com/docker/docker/api/types/mount"
|
||||
)
|
||||
|
||||
// read-write modes
|
||||
|
@ -18,14 +20,7 @@ var roModes = map[string]bool{
|
|||
"ro": true,
|
||||
}
|
||||
|
||||
var platformRawValidationOpts = []func(*validateOpts){
|
||||
// filepath.IsAbs is weird on Windows:
|
||||
// `c:` is not considered an absolute path
|
||||
// `c:\` is considered an absolute path
|
||||
// In any case, the regex matching below ensures absolute paths
|
||||
// TODO: consider this a bug with filepath.IsAbs (?)
|
||||
func(o *validateOpts) { o.skipAbsolutePathCheck = true },
|
||||
}
|
||||
var platformRawValidationOpts = []func(*validateOpts){}
|
||||
|
||||
const (
|
||||
// Spec should be in the format [source:]destination[:mode]
|
||||
|
@ -49,11 +44,13 @@ const (
|
|||
RXHostDir = `[a-z]:\\(?:[^\\/:*?"<>|\r\n]+\\?)*`
|
||||
// RXName is the second option of a source
|
||||
RXName = `[^\\/:*?"<>|\r\n]+`
|
||||
// RXPipe is a named path pipe (starts with `\\.\pipe\`, possibly with / instead of \)
|
||||
RXPipe = `[/\\]{2}.[/\\]pipe[/\\][^:*?"<>|\r\n]+`
|
||||
// RXReservedNames are reserved names not possible on Windows
|
||||
RXReservedNames = `(con)|(prn)|(nul)|(aux)|(com[1-9])|(lpt[1-9])`
|
||||
|
||||
// RXSource is the combined possibilities for a source
|
||||
RXSource = `((?P<source>((` + RXHostDir + `)|(` + RXName + `))):)?`
|
||||
RXSource = `((?P<source>((` + RXHostDir + `)|(` + RXName + `)|(` + RXPipe + `))):)?`
|
||||
|
||||
// Source. Can be either a host directory, a name, or omitted:
|
||||
// HostDir:
|
||||
|
@ -69,8 +66,10 @@ const (
|
|||
// - And then followed by a colon which is not in the capture group
|
||||
// - And can be optional
|
||||
|
||||
// RXDestinationDir is the file path option for the mount destination
|
||||
RXDestinationDir = `([a-z]):((?:\\[^\\/:*?"<>\r\n]+)*\\?)`
|
||||
// RXDestination is the regex expression for the mount destination
|
||||
RXDestination = `(?P<destination>([a-z]):((?:\\[^\\/:*?"<>\r\n]+)*\\?))`
|
||||
RXDestination = `(?P<destination>(` + RXDestinationDir + `)|(` + RXPipe + `))`
|
||||
// Destination (aka container path):
|
||||
// - Variation on hostdir but can be a drive followed by colon as well
|
||||
// - If a path, must be absolute. Can include spaces
|
||||
|
@ -140,6 +139,15 @@ func splitRawSpec(raw string) ([]string, error) {
|
|||
return split, nil
|
||||
}
|
||||
|
||||
func detectMountType(p string) mounttypes.Type {
|
||||
if strings.HasPrefix(filepath.FromSlash(p), `\\.\pipe\`) {
|
||||
return mounttypes.TypeNamedPipe
|
||||
} else if filepath.IsAbs(p) {
|
||||
return mounttypes.TypeBind
|
||||
}
|
||||
return mounttypes.TypeVolume
|
||||
}
|
||||
|
||||
// IsVolumeNameValid checks a volume name in a platform specific manner.
|
||||
func IsVolumeNameValid(name string) (bool, error) {
|
||||
nameExp := regexp.MustCompile(`^` + RXName + `$`)
|
||||
|
@ -186,8 +194,19 @@ func convertSlash(p string) string {
|
|||
return filepath.FromSlash(p)
|
||||
}
|
||||
|
||||
// isAbsPath returns whether a path is absolute for the purposes of mounting into a container
|
||||
// (absolute paths, drive letter paths such as X:, and paths starting with `\\.\` to support named pipes).
|
||||
func isAbsPath(p string) bool {
|
||||
return filepath.IsAbs(p) ||
|
||||
strings.HasPrefix(p, `\\.\`) ||
|
||||
(len(p) == 2 && p[1] == ':' && ((p[0] >= 'a' && p[0] <= 'z') || (p[0] >= 'A' && p[0] <= 'Z')))
|
||||
}
|
||||
|
||||
// Do not clean plain drive letters or paths starting with `\\.\`.
|
||||
var cleanRegexp = regexp.MustCompile(`^([a-z]:|[/\\]{2}\.[/\\].*)$`)
|
||||
|
||||
func clean(p string) string {
|
||||
if match, _ := regexp.MatchString("^[a-z]:$", p); match {
|
||||
if match := cleanRegexp.MatchString(p); match {
|
||||
return p
|
||||
}
|
||||
return filepath.Clean(p)
|
||||
|
|
Loading…
Reference in a new issue