fix loading absolute paths on Windows (#9055)

fix: https://github.com/open-policy-agent/opa/issues/4521

---------

Signed-off-by: Sebastian Spaink <sebastianspaink@gmail.com>
This commit is contained in:
Sebastian Spaink
2026-08-27 10:11:35 -05:00
committed by GitHub
parent 48310c63ac
commit 54bf3293de
5 changed files with 191 additions and 3 deletions
+8
View File
@@ -130,6 +130,14 @@ File paths can be specified as URLs to resolve ambiguity in paths containing col
$ ` + executable + ` run file:///c:/path/to/data.json
On Windows, a path beginning with a drive letter is read as a path rather than a
destination, so "C:\path\to\data.json" loads at the root of the data document. A
single-character destination must therefore be followed by a qualified path:
$ ` + executable + ` run c:C:\path\to\data.json
Which will load the "data.json" file at path "data.c".
URL paths to remote public bundles (http or https) will be parsed as shorthand
configuration equivalent of using repeated --set flags to accomplish the same:
File diff suppressed because one or more lines are too long
+28
View File
@@ -12,6 +12,7 @@ import (
"io/fs"
"os"
"path/filepath"
"runtime"
"strings"
"sigs.k8s.io/yaml"
@@ -29,6 +30,9 @@ import (
"github.com/open-policy-agent/opa/v1/util"
)
// goos is overridden in tests to exercise Windows path handling on other platforms.
var goos = runtime.GOOS
// Result represents the result of successfully loading zero or more files.
type Result struct {
Documents map[string]any
@@ -592,6 +596,11 @@ func SplitPrefix(path string) ([]string, string) {
if strings.Index(path, "://") == strings.Index(path, ":") {
return nil, path
}
// On Windows, a leading colon can belong to the path itself, separating the
// volume name from the rest of the path, rather than to a data prefix.
if hasWindowsVolumeName(path) {
return nil, path
}
parts := strings.SplitN(path, ":", 2)
if len(parts) == 2 && len(parts[0]) > 0 {
return strings.Split(parts[0], "."), parts[1]
@@ -599,6 +608,25 @@ func SplitPrefix(path string) ([]string, string) {
return nil, path
}
// hasWindowsVolumeName returns true on Windows if path begins with a volume
// name, i.e. a drive letter followed by a colon and a separator (c:/foo) or a
// UNC/device prefix (\\?\c:\foo), but not a drive-relative path (c:foo), which
// is read as a single-character data prefix instead.
func hasWindowsVolumeName(path string) bool {
if goos != "windows" || len(path) < 3 {
return false
}
// UNC and device paths, e.g. \\server\share or \\?\c:\foo. These aren't all
// loadable -- UNC reads are rejected outright -- but they're never prefixes,
// and splitting them would hide the path from that check.
if isSlash(path[0]) && isSlash(path[1]) {
return true
}
// Drive-rooted paths, e.g. c:/foo.
c := path[0]
return ('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z') && path[1] == ':' && isSlash(path[2])
}
func (l *Result) merge(path string, result any) error {
switch result := result.(type) {
case bundle.Bundle:
+111 -2
View File
@@ -18,6 +18,7 @@ import (
"slices"
"strings"
"testing"
"testing/fstest"
"github.com/open-policy-agent/opa/v1/ast"
astJSON "github.com/open-policy-agent/opa/v1/ast/json"
@@ -1276,6 +1277,7 @@ func TestSplitPrefix(t *testing.T) {
tests := []struct {
input string
goos string
wantParts []string
wantPath string
}{
@@ -1311,21 +1313,128 @@ func TestSplitPrefix(t *testing.T) {
wantParts: []string{"x", "y"},
wantPath: "file:///c:/a/b/c",
},
{
input: "c:/a/b/c",
goos: "windows",
wantPath: "c:/a/b/c",
},
{
input: `C:\a\b\c`,
goos: "windows",
wantPath: `C:\a\b\c`,
},
{
input: "c:a/b",
goos: "windows",
wantParts: []string{"c"},
wantPath: "a/b",
},
{
// Only a single character can name a drive, so a longer prefix
// over a rooted path is still a prefix on Windows.
input: "foo:/a/b",
goos: "windows",
wantParts: []string{"foo"},
wantPath: "/a/b",
},
{
input: "x.y:/a/b",
goos: "windows",
wantParts: []string{"x", "y"},
wantPath: "/a/b",
},
{
input: "x.y:c:/a/b",
goos: "windows",
wantParts: []string{"x", "y"},
wantPath: "c:/a/b",
},
{
// A drive-rooted path is read as a path, so a single-character
// prefix over a rooted path is spelled by qualifying the path.
input: "c:C:/a/b",
goos: "windows",
wantParts: []string{"c"},
wantPath: "C:/a/b",
},
{
input: `\\?\c:\a\b`,
goos: "windows",
wantPath: `\\?\c:\a\b`,
},
{
input: "//?/c:/a/b",
goos: "windows",
wantPath: "//?/c:/a/b",
},
{
input: `\\.\c:\a\b`,
goos: "windows",
wantPath: `\\.\c:\a\b`,
},
{
input: `\\server\share\a`,
goos: "windows",
wantPath: `\\server\share\a`,
},
{
input: "c:/a/b/c",
goos: "linux",
wantParts: []string{"c"},
wantPath: "/a/b/c",
},
{
input: `\\?\c:\a\b`,
goos: "linux",
wantParts: []string{`\\?\c`},
wantPath: `\a\b`,
},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
t.Run(tc.goos+tc.input, func(t *testing.T) {
if tc.goos != "" {
prev := goos
goos = tc.goos
t.Cleanup(func() { goos = prev })
}
parts, gotPath := SplitPrefix(tc.input)
if !slices.Equal(parts, tc.wantParts) {
t.Errorf("wanted parts %v but got %v", tc.wantParts, parts)
}
if gotPath != tc.wantPath {
t.Errorf("wanted path %q but got %q", gotPath, tc.wantPath)
t.Errorf("wanted path %q but got %q", tc.wantPath, gotPath)
}
})
}
}
func TestLoadWindowsAbsolutePath(t *testing.T) {
prev := goos
goos = "windows"
t.Cleanup(func() { goos = prev })
fsys := fstest.MapFS{
"c:/policies/foo.json": &fstest.MapFile{Data: []byte(`{"a": [1,2,3]}`)},
"c:/policies/bar.rego": &fstest.MapFile{Data: []byte("package bar\n")},
}
loaded, err := NewFileLoader().WithFS(fsys).All([]string{"c:/policies"})
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
expected := parseJSON(`{"a": [1,2,3]}`)
if !reflect.DeepEqual(loaded.Documents, expected) {
t.Fatalf("Expected %v but got: %v", expected, loaded.Documents)
}
if _, ok := loaded.Modules["c:/policies/bar.rego"]; !ok {
t.Fatalf("Expected c:/policies/bar.rego to be loaded, got: %v", loaded.Modules)
}
}
func TestLoadRegos(t *testing.T) {
files := map[string]string{
"/x.rego": `
@@ -0,0 +1,43 @@
# absolute path to a data file, which must load at the root of the data
# document rather than under a document named after the drive letter
exec $OPA eval --format pretty --data $WORK/data.json data.yay --fail
stdout '^true$'
! stderr .
# absolute path to a policy file
exec $OPA eval --format pretty --data $WORK/policy.rego --input $WORK/data.json data.test.allow --fail
stdout '^true$'
! stderr .
# absolute path to a directory
exec $OPA eval --format pretty --data $WORK/policies --input $WORK/data.json data.test.allow --fail
stdout '^true$'
! stderr .
# absolute path to a bundle
exec $OPA build --bundle $WORK/policies --output $WORK/bundle.tar.gz
exec $OPA eval --format pretty --bundle $WORK/bundle.tar.gz --input $WORK/data.json data.test.allow --fail
stdout '^true$'
! stderr .
# a data prefix still applies to an absolute path
exec $OPA eval --format pretty --data x.y:$WORK/data.json data.x.y.yay --fail
stdout '^true$'
! stderr .
# a single-character prefix is not mistaken for a drive letter, since the
# absolute path following it is itself qualified
exec $OPA eval --format pretty --data c:$WORK/data.json data.c.yay --fail
stdout '^true$'
! stderr .
-- data.json --
{"yay": true}
-- policy.rego --
package test
allow if input.yay
-- policies/policy.rego --
package test
allow if input.yay