From 54bf3293debffa23bc34a386eabb493b97c8bb4d Mon Sep 17 00:00:00 2001 From: Sebastian Spaink Date: Thu, 27 Aug 2026 10:11:35 -0500 Subject: [PATCH] fix loading absolute paths on Windows (#9055) fix: https://github.com/open-policy-agent/opa/issues/4521 --------- Signed-off-by: Sebastian Spaink --- cmd/run.go | 8 ++ docs/src/data/cli.json | 2 +- v1/loader/loader.go | 28 +++++ v1/loader/loader_test.go | 113 +++++++++++++++++++- v1/test/cli/script/load_absolute_path.txtar | 43 ++++++++ 5 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 v1/test/cli/script/load_absolute_path.txtar diff --git a/cmd/run.go b/cmd/run.go index 2d30c68768..4dc17aa8a7 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -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: diff --git a/docs/src/data/cli.json b/docs/src/data/cli.json index c049aa3132..31ce9c17ac 100644 --- a/docs/src/data/cli.json +++ b/docs/src/data/cli.json @@ -1370,7 +1370,7 @@ } ], "id": "run", - "long": "Start an instance of opa.\n\nTo run the interactive shell:\n\n $ opa run\n\nTo run the server:\n\n $ opa run -s\n\nThe 'run' command starts an instance of the opa runtime. The opa runtime can be\nstarted as an interactive shell or a server.\n\nWhen the runtime is started as a shell, users can define rules and evaluate\nexpressions interactively. When the runtime is started as a server, opa exposes\nan HTTP API for managing policies, reading and writing data, and executing\nqueries.\n\nThe runtime can be initialized with one or more files that contain policies or\ndata. If the '--bundle' option is specified the paths will be treated as policy\nbundles and loaded following standard bundle conventions. The path can be a\ncompressed archive file or a directory which will be treated as a bundle.\nWithout the '--bundle' flag opa will recursively load ALL rego, JSON, and YAML\nfiles.\n\nWhen loading from directories, only files with known extensions are considered.\nThe current set of file extensions that opa will consider are:\n\n .json # JSON data\n .yaml or .yml # YAML data\n .rego # Rego file\n\nNon-bundle data file and directory paths can be prefixed with the desired\ndestination in the data document with the following syntax:\n\n \u003cdotted-path\u003e:\u003cfile-path\u003e\n\nTo set a data file as the input document in the interactive shell use the\n\"repl.input\" path prefix with the input file:\n\n repl.input:\u003cfile-path\u003e\n\nExample:\n\n $ opa run repl.input:input.json\n\nWhich will load the \"input.json\" file at path \"data.repl.input\".\n\nUse the \"help input\" command in the interactive shell to see more options.\n\n\nFile paths can be specified as URLs to resolve ambiguity in paths containing colons:\n\n $ opa run file:///c:/path/to/data.json\n\nURL paths to remote public bundles (http or https) will be parsed as shorthand\nconfiguration equivalent of using repeated --set flags to accomplish the same:\n\n\t$ opa run -s https://example.com/bundles/bundle.tar.gz\n\nThe above shorthand command is identical to:\n\n $ opa run -s --set \"services.cli1.url=https://example.com\" \\\n --set \"bundles.cli1.service=cli1\" \\\n --set \"bundles.cli1.resource=/bundles/bundle.tar.gz\" \\\n --set \"bundles.cli1.persist=true\"\n\nThe 'run' command can also verify the signature of a signed bundle.\nA signed bundle is a normal opa bundle that includes a file\nnamed \".signatures.json\". For more information on signed bundles\nsee https://www.openpolicyagent.org/docs/latest/management-bundles/#signing.\n\nThe key to verify the signature of signed bundle can be provided\nusing the --verification-key flag. For example, for RSA family of algorithms,\nthe command expects a PEM file containing the public key.\nFor HMAC family of algorithms (eg. HS256), the secret can be provided\nusing the --verification-key flag.\n\nThe --verification-key-id flag can be used to optionally specify a name for the\nkey provided using the --verification-key flag.\n\nThe --signing-alg flag can be used to specify the signing algorithm.\nThe 'run' command uses RS256 (by default) as the signing algorithm.\n\nThe --scope flag can be used to specify the scope to use for\nbundle signature verification.\n\nExample:\n\n $ opa run --verification-key secret --signing-alg HS256 --bundle bundle.tar.gz\n\nThe 'run' command will read the bundle \"bundle.tar.gz\", check the\n\".signatures.json\" file and perform verification using the provided key.\nAn error will be generated if \"bundle.tar.gz\" does not contain a \".signatures.json\" file.\nFor more information on the bundle verification process see\nhttps://www.openpolicyagent.org/docs/latest/management-bundles/#signature-verification.\n\nThe 'run' command can ONLY be used with the --bundle flag to verify signatures\nfor existing bundle files or directories following the bundle structure.\n\nTo skip bundle verification, use the --skip-verify flag.\n\nThe --watch flag can be used to monitor policy and data file-system changes. When a change is detected, the updated policy\nand data is reloaded into OPA. Watching individual files (rather than directories) is generally not recommended as some\nupdates might cause them to be dropped by OPA.\n\nOPA will automatically perform type checking based on a schema inferred from known input documents and report any errors\nresulting from the schema check. Currently this check is performed on OPA's Authorization Policy Input document and will\nbe expanded in the future. To disable this, use the --skip-known-schema-check flag.\n\nThe --v0-compatible flag can be used to opt-in to OPA features and behaviors that were the default in OPA v0.x.\nBehaviors enabled by this flag include:\n- setting OPA's listening address to \":8181\" by default, corresponding to listening on every network interface.\n- expecting v0 Rego syntax in policy modules instead of the default v1 Rego syntax.\n\nThe --tls-cipher-suites flag can be used to specify the list of enabled TLS 1.0–1.2 cipher suites. Note that TLS 1.3\ncipher suites are not configurable. Following are the supported TLS 1.0 - 1.2 cipher suites (IANA):\nTLS_RSA_WITH_RC4_128_SHA, TLS_RSA_WITH_3DES_EDE_CBC_SHA, TLS_RSA_WITH_AES_128_CBC_SHA, TLS_RSA_WITH_AES_256_CBC_SHA,\nTLS_RSA_WITH_AES_128_CBC_SHA256, TLS_RSA_WITH_AES_128_GCM_SHA256, TLS_RSA_WITH_AES_256_GCM_SHA384, TLS_ECDHE_ECDSA_WITH_RC4_128_SHA,\nTLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, TLS_ECDHE_RSA_WITH_RC4_128_SHA, TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA,\nTLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256, TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256,\nTLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,\nTLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256\n\nSee https://godoc.org/crypto/tls#pkg-constants for more information.\n", + "long": "Start an instance of opa.\n\nTo run the interactive shell:\n\n $ opa run\n\nTo run the server:\n\n $ opa run -s\n\nThe 'run' command starts an instance of the opa runtime. The opa runtime can be\nstarted as an interactive shell or a server.\n\nWhen the runtime is started as a shell, users can define rules and evaluate\nexpressions interactively. When the runtime is started as a server, opa exposes\nan HTTP API for managing policies, reading and writing data, and executing\nqueries.\n\nThe runtime can be initialized with one or more files that contain policies or\ndata. If the '--bundle' option is specified the paths will be treated as policy\nbundles and loaded following standard bundle conventions. The path can be a\ncompressed archive file or a directory which will be treated as a bundle.\nWithout the '--bundle' flag opa will recursively load ALL rego, JSON, and YAML\nfiles.\n\nWhen loading from directories, only files with known extensions are considered.\nThe current set of file extensions that opa will consider are:\n\n .json # JSON data\n .yaml or .yml # YAML data\n .rego # Rego file\n\nNon-bundle data file and directory paths can be prefixed with the desired\ndestination in the data document with the following syntax:\n\n \u003cdotted-path\u003e:\u003cfile-path\u003e\n\nTo set a data file as the input document in the interactive shell use the\n\"repl.input\" path prefix with the input file:\n\n repl.input:\u003cfile-path\u003e\n\nExample:\n\n $ opa run repl.input:input.json\n\nWhich will load the \"input.json\" file at path \"data.repl.input\".\n\nUse the \"help input\" command in the interactive shell to see more options.\n\n\nFile paths can be specified as URLs to resolve ambiguity in paths containing colons:\n\n $ opa run file:///c:/path/to/data.json\n\nOn Windows, a path beginning with a drive letter is read as a path rather than a\ndestination, so \"C:\\path\\to\\data.json\" loads at the root of the data document. A\nsingle-character destination must therefore be followed by a qualified path:\n\n $ opa run c:C:\\path\\to\\data.json\n\nWhich will load the \"data.json\" file at path \"data.c\".\n\nURL paths to remote public bundles (http or https) will be parsed as shorthand\nconfiguration equivalent of using repeated --set flags to accomplish the same:\n\n\t$ opa run -s https://example.com/bundles/bundle.tar.gz\n\nThe above shorthand command is identical to:\n\n $ opa run -s --set \"services.cli1.url=https://example.com\" \\\n --set \"bundles.cli1.service=cli1\" \\\n --set \"bundles.cli1.resource=/bundles/bundle.tar.gz\" \\\n --set \"bundles.cli1.persist=true\"\n\nThe 'run' command can also verify the signature of a signed bundle.\nA signed bundle is a normal opa bundle that includes a file\nnamed \".signatures.json\". For more information on signed bundles\nsee https://www.openpolicyagent.org/docs/latest/management-bundles/#signing.\n\nThe key to verify the signature of signed bundle can be provided\nusing the --verification-key flag. For example, for RSA family of algorithms,\nthe command expects a PEM file containing the public key.\nFor HMAC family of algorithms (eg. HS256), the secret can be provided\nusing the --verification-key flag.\n\nThe --verification-key-id flag can be used to optionally specify a name for the\nkey provided using the --verification-key flag.\n\nThe --signing-alg flag can be used to specify the signing algorithm.\nThe 'run' command uses RS256 (by default) as the signing algorithm.\n\nThe --scope flag can be used to specify the scope to use for\nbundle signature verification.\n\nExample:\n\n $ opa run --verification-key secret --signing-alg HS256 --bundle bundle.tar.gz\n\nThe 'run' command will read the bundle \"bundle.tar.gz\", check the\n\".signatures.json\" file and perform verification using the provided key.\nAn error will be generated if \"bundle.tar.gz\" does not contain a \".signatures.json\" file.\nFor more information on the bundle verification process see\nhttps://www.openpolicyagent.org/docs/latest/management-bundles/#signature-verification.\n\nThe 'run' command can ONLY be used with the --bundle flag to verify signatures\nfor existing bundle files or directories following the bundle structure.\n\nTo skip bundle verification, use the --skip-verify flag.\n\nThe --watch flag can be used to monitor policy and data file-system changes. When a change is detected, the updated policy\nand data is reloaded into OPA. Watching individual files (rather than directories) is generally not recommended as some\nupdates might cause them to be dropped by OPA.\n\nOPA will automatically perform type checking based on a schema inferred from known input documents and report any errors\nresulting from the schema check. Currently this check is performed on OPA's Authorization Policy Input document and will\nbe expanded in the future. To disable this, use the --skip-known-schema-check flag.\n\nThe --v0-compatible flag can be used to opt-in to OPA features and behaviors that were the default in OPA v0.x.\nBehaviors enabled by this flag include:\n- setting OPA's listening address to \":8181\" by default, corresponding to listening on every network interface.\n- expecting v0 Rego syntax in policy modules instead of the default v1 Rego syntax.\n\nThe --tls-cipher-suites flag can be used to specify the list of enabled TLS 1.0–1.2 cipher suites. Note that TLS 1.3\ncipher suites are not configurable. Following are the supported TLS 1.0 - 1.2 cipher suites (IANA):\nTLS_RSA_WITH_RC4_128_SHA, TLS_RSA_WITH_3DES_EDE_CBC_SHA, TLS_RSA_WITH_AES_128_CBC_SHA, TLS_RSA_WITH_AES_256_CBC_SHA,\nTLS_RSA_WITH_AES_128_CBC_SHA256, TLS_RSA_WITH_AES_128_GCM_SHA256, TLS_RSA_WITH_AES_256_GCM_SHA384, TLS_ECDHE_ECDSA_WITH_RC4_128_SHA,\nTLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, TLS_ECDHE_RSA_WITH_RC4_128_SHA, TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA,\nTLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256, TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256,\nTLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,\nTLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256\n\nSee https://godoc.org/crypto/tls#pkg-constants for more information.\n", "parent_flags": null, "short": "Start opa in interactive or server mode", "use": "run", diff --git a/v1/loader/loader.go b/v1/loader/loader.go index d97e3e5409..f78f9fb1d2 100644 --- a/v1/loader/loader.go +++ b/v1/loader/loader.go @@ -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: diff --git a/v1/loader/loader_test.go b/v1/loader/loader_test.go index 79188526af..a6bebe8bb4 100644 --- a/v1/loader/loader_test.go +++ b/v1/loader/loader_test.go @@ -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": ` diff --git a/v1/test/cli/script/load_absolute_path.txtar b/v1/test/cli/script/load_absolute_path.txtar new file mode 100644 index 0000000000..c16c8f1f34 --- /dev/null +++ b/v1/test/cli/script/load_absolute_path.txtar @@ -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