mirror of
https://github.com/open-policy-agent/opa.git
synced 2026-08-27 18:55:54 -06:00
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:
@@ -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
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user