Repository: compose-spec/compose-go
Pull request #779 (2025-04-28 08:06:00)

update IsWindowsAbs code and export func
update `IsWindowsPath` code to reflect latest codebase from go SDK https://github.com/golang/go/blob/master/src/internal/filepathlite/path_windows.go

export func so it can be used by Docker Compose while preparing build mounts, to detect a absolute remote windows path (see https://github.com/docker/compose/issues/12750)


Patch:
diff --git a/.golangci.yml b/.golangci.yml
index 085fe91..fca2301 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -72,6 +72,8 @@ linters:
       - third_party$
       - builtin$
       - examples$
+      - paths/windows_path.go
+
 issues:
   max-issues-per-linter: 0
   max-same-issues: 0
diff --git a/paths/unix.go b/paths/unix.go
index d47f936..ccfe9fa 100644
--- a/paths/unix.go
+++ b/paths/unix.go
@@ -35,7 +35,7 @@ func (r *relativePathsResolver) maybeUnixPath(a any) (any, error) {
 	// Note that this is not required for Docker for Windows when specifying
 	// a local Windows path, because Docker for Windows translates the Windows
 	// path into a valid path within the VM.
-	if !path.IsAbs(p) && !isWindowsAbs(p) {
+	if !path.IsAbs(p) && !IsWindowsAbs(p) {
 		if filepath.IsAbs(p) {
 			return p, nil
 		}
diff --git a/paths/windows_path.go b/paths/windows_path.go
index 968d8ed..c296f59 100644
--- a/paths/windows_path.go
+++ b/paths/windows_path.go
@@ -16,66 +16,218 @@
 
 package paths
 
+// This file contains utilities to check for Windows absolute paths on Linux.
+// The code in this file was largely copied from the Golang filepath package
+// https://github.com/golang/go/blob/master/src/internal/filepathlite/path_windows.go
+
+import "slices"
+
 // Copyright 2010 The Go Authors. All rights reserved.
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 // https://github.com/golang/go/blob/master/LICENSE
 
-// This file contains utilities to check for Windows absolute paths on Linux.
-// The code in this file was largely copied from the Golang filepath package
-// https://github.com/golang/go/blob/1d0e94b1e13d5e8a323a63cd1cc1ef95290c9c36/src/path/filepath/path_windows.go#L12-L65
-
-func isSlash(c uint8) bool {
+func IsPathSeparator(c uint8) bool {
 	return c == '\\' || c == '/'
 }
 
-// isAbs reports whether the path is a Windows absolute path.
-func isWindowsAbs(path string) (b bool) {
+// IsWindowsAbs reports whether the path is absolute.
+// copied from IsAbs(path string) (b bool) from internal.filetpathlite
+func IsWindowsAbs(path string) (b bool) {
 	l := volumeNameLen(path)
 	if l == 0 {
 		return false
 	}
+	// If the volume name starts with a double slash, this is an absolute path.
+	if IsPathSeparator(path[0]) && IsPathSeparator(path[1]) {
+		return true
+	}
 	path = path[l:]
 	if path == "" {
 		return false
 	}
-	return isSlash(path[0])
+	return IsPathSeparator(path[0])
 }
 
 // volumeNameLen returns length of the leading volume name on Windows.
 // It returns 0 elsewhere.
+//
+// See:
+// https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats
+// https://googleprojectzero.blogspot.com/2016/02/the-definitive-guide-on-win32-to-nt.html
 func volumeNameLen(path string) int {
-	if len(path) < 2 {
+	switch {
+	case len(path) >= 2 && path[1] == ':':
+		// Path starts with a drive letter.
+		//
+		// Not all Windows functions necessarily enforce the requirement that
+		// drive letters be in the set A-Z, and we don't try to here.
+		//
+		// We don't handle the case of a path starting with a non-ASCII character,
+		// in which case the "drive letter" might be multiple bytes long.
+		return 2
+
+	case len(path) == 0 || !IsPathSeparator(path[0]):
+		// Path does not have a volume component.
 		return 0
+
+	case pathHasPrefixFold(path, `\\.\UNC`):
+		// We're going to treat the UNC host and share as part of the volume
+		// prefix for historical reasons, but this isn't really principled;
+		// Windows's own GetFullPathName will happily remove the first
+		// component of the path in this space, converting
+		// \\.\unc\a\b\..\c into \\.\unc\a\c.
+		return uncLen(path, len(`\\.\UNC\`))
+
+	case pathHasPrefixFold(path, `\\.`) ||
+		pathHasPrefixFold(path, `\\?`) || pathHasPrefixFold(path, `\??`):
+		// Path starts with \\.\, and is a Local Device path; or
+		// path starts with \\?\ or \??\ and is a Root Local Device path.
+		//
+		// We treat the next component after the \\.\ prefix as
+		// part of the volume name, which means Clean(`\\?\c:\`)
+		// won't remove the trailing \. (See #64028.)
+		if len(path) == 3 {
+			return 3 // exactly \\.
+		}
+		_, rest, ok := cutPath(path[4:])
+		if !ok {
+			return len(path)
+		}
+		return len(path) - len(rest) - 1
+
+	case len(path) >= 2 && IsPathSeparator(path[1]):
+		// Path starts with \\, and is a UNC path.
+		return uncLen(path, 2)
 	}
-	// with drive letter
-	c := path[0]
-	if path[1] == ':' && ('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z') {
-		return 2
+	return 0
+}
+
+// pathHasPrefixFold tests whether the path s begins with prefix,
+// ignoring case and treating all path separators as equivalent.
+// If s is longer than prefix, then s[len(prefix)] must be a path separator.
+func pathHasPrefixFold(s, prefix string) bool {
+	if len(s) < len(prefix) {
+		return false
 	}
-	// is it UNC? https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx
-	if l := len(path); l >= 5 && isSlash(path[0]) && isSlash(path[1]) &&
-		!isSlash(path[2]) && path[2] != '.' {
-		// first, leading `\\` and next shouldn't be `\`. its server name.
-		for n := 3; n < l-1; n++ {
-			// second, next '\' shouldn't be repeated.
-			if isSlash(path[n]) {
-				n++
-				// third, following something characters. its share name.
-				if !isSlash(path[n]) {
-					if path[n] == '.' {
-						break
-					}
-					for ; n < l; n++ {
-						if isSlash(path[n]) {
-							break
-						}
-					}
-					return n
-				}
-				break
+	for i := 0; i < len(prefix); i++ {
+		if IsPathSeparator(prefix[i]) {
+			if !IsPathSeparator(s[i]) {
+				return false
 			}
+		} else if toUpper(prefix[i]) != toUpper(s[i]) {
+			return false
 		}
 	}
-	return 0
+	if len(s) > len(prefix) && !IsPathSeparator(s[len(prefix)]) {
+		return false
+	}
+	return true
+}
+
+// uncLen returns the length of the volume prefix of a UNC path.
+// prefixLen is the prefix prior to the start of the UNC host;
+// for example, for "//host/share", the prefixLen is len("//")==2.
+func uncLen(path string, prefixLen int) int {
+	count := 0
+	for i := prefixLen; i < len(path); i++ {
+		if IsPathSeparator(path[i]) {
+			count++
+			if count == 2 {
+				return i
+			}
+		}
+	}
+	return len(path)
+}
+
+// cutPath slices path around the first path separator.
+func cutPath(path string) (before, after string, found bool) {
+	for i := range path {
+		if IsPathSeparator(path[i]) {
+			return path[:i], path[i+1:], true
+		}
+	}
+	return path, "", false
+}
+
+// postClean adjusts the results of Clean to avoid turning a relative path
+// into an absolute or rooted one.
+func postClean(out *lazybuf) {
+	if out.volLen != 0 || out.buf == nil {
+		return
+	}
+	// If a ':' appears in the path element at the start of a path,
+	// insert a .\ at the beginning to avoid converting relative paths
+	// like a/../c: into c:.
+	for _, c := range out.buf {
+		if IsPathSeparator(c) {
+			break
+		}
+		if c == ':' {
+			out.prepend('.', Separator)
+			return
+		}
+	}
+	// If a path begins with \??\, insert a \. at the beginning
+	// to avoid converting paths like \a\..\??\c:\x into \??\c:\x
+	// (equivalent to c:\x).
+	if len(out.buf) >= 3 && IsPathSeparator(out.buf[0]) && out.buf[1] == '?' && out.buf[2] == '?' {
+		out.prepend(Separator, '.')
+	}
+}
+
+func toUpper(c byte) byte {
+	if 'a' <= c && c <= 'z' {
+		return c - ('a' - 'A')
+	}
+	return c
+}
+
+const (
+	Separator = '\\' // OS-specific path separator
+)
+
+// A lazybuf is a lazily constructed path buffer.
+// It supports append, reading previously appended bytes,
+// and retrieving the final string. It does not allocate a buffer
+// to hold the output until that output diverges from s.
+type lazybuf struct {
+	path       string
+	buf        []byte
+	w          int
+	volAndPath string
+	volLen     int
+}
+
+func (b *lazybuf) index(i int) byte {
+	if b.buf != nil {
+		return b.buf[i]
+	}
+	return b.path[i]
+}
+
+func (b *lazybuf) append(c byte) {
+	if b.buf == nil {
+		if b.w < len(b.path) && b.path[b.w] == c {
+			b.w++
+			return
+		}
+		b.buf = make([]byte, len(b.path))
+		copy(b.buf, b.path[:b.w])
+	}
+	b.buf[b.w] = c
+	b.w++
+}
+
+func (b *lazybuf) prepend(prefix ...byte) {
+	b.buf = slices.Insert(b.buf, 0, prefix...)
+	b.w += len(prefix)
+}
+
+func (b *lazybuf) string() string {
+	if b.buf == nil {
+		return b.volAndPath[:b.volLen+b.w]
+	}
+	return b.volAndPath[:b.volLen] + string(b.buf[:b.w])
 }


Test patch:
diff --git a/paths/windows_path_test.go b/paths/windows_path_test.go
index afbc289..d290b79 100644
--- a/paths/windows_path_test.go
+++ b/paths/windows_path_test.go
@@ -71,7 +71,7 @@ func TestIsAbs(t *testing.T) {
 	}
 
 	for _, test := range tests {
-		if r := isWindowsAbs(test.path); r != test.isAbs {
+		if r := IsWindowsAbs(test.path); r != test.isAbs {
 			t.Errorf("IsAbs(%q) = %v, want %v", test.path, r, test.isAbs)
 		}
 	}