Skip to content

Commit 64ddcf1

Browse files
authored
Introduce runtime policies for Ferret CLI: add support for filesystem and HTTP policies (#41)
* Introduce runtime policies for Ferret CLI: add support for filesystem and HTTP policies, update tests, and enhance documentation. * Reject binary flags in unsupported runtimes: validate runtime options, update helpers, tests, and use `slices.Concat` for argument assembly.
1 parent c985121 commit 64ddcf1

23 files changed

Lines changed: 1215 additions & 253 deletions

README.md

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -664,9 +664,9 @@ RETURN T::EQ(payload.id, "123")
664664

665665
Mock API entries use the same binding syntax as static serving: `<path>`, `<path>:<port>`, `<path>@<alias>`, and `<path>@<alias>:<port>`. `--serve-bind` and `--serve-host` also apply to mock API servers.
666666

667-
### 🔒 Built-in Filesystem Policy
667+
### 🔒 Filesystem Policy
668668

669-
The built-in runtime exposes FQL filesystem functions through a sandbox rooted at Lab's current working directory. Use `--policy-fs-root` to select a narrower relative or absolute root, and add `--policy-fs-read-only` to permit reads while rejecting writes, directory changes, and removals.
669+
The built-in runtime exposes FQL filesystem functions through a sandbox rooted at Lab's current working directory. Use `--policy-fs-root` to select a narrower relative or absolute root, and add `--policy-fs-read-only` to permit reads while rejecting writes, directory changes, and removals. When `--runtime=bin:...` selects a Ferret CLI v2 binary, Lab forwards the same explicitly configured policy values to `ferret run`.
670670

671671
```bash
672672
lab run \
@@ -697,7 +697,7 @@ lab run \
697697

698698
When the runtime URL already includes a path, Lab sends `run` requests to that exact path. The optional `runtime-param=path` value overrides the run endpoint only. `lab version --runtime=...` uses the runtime URL path and requests its sibling `/info` endpoint.
699699

700-
The `--policy-fs-*` and `--policy-http-*` flags configure only Lab's built-in Ferret runtime. Lab rejects them when `--runtime` selects an HTTP or binary adapter because policy enforcement belongs to that external runtime.
700+
Lab rejects `--policy-fs-*` and `--policy-http-*` options for HTTP runtimes because their request protocol has no policy contract. Built-in and Ferret CLI v2 binary runtimes both support these options.
701701

702702
The HTTP runtime sends POST requests with:
703703

@@ -712,25 +712,35 @@ The HTTP runtime sends POST requests with:
712712

713713
#### External Binary Runtime
714714

715-
Use custom Ferret CLI installations:
715+
Use Ferret CLI v2-compatible installations. Lab invokes the binary as `ferret run`, passes the FQL source through stdin, and serializes test parameters as `--param=name=<JSON>` arguments.
716716

717717
```bash
718718
# Use specific Ferret binary
719719
lab run --runtime=bin:./custom-ferret tests/
720720
721-
# With runtime params forwarded as --param entries
721+
# With shared runtime params forwarded as --param entries
722722
lab run \
723-
--runtime=bin:/usr/local/bin/ferret-v0.18 \
723+
--runtime=bin:/usr/local/bin/ferret \
724724
--runtime-param=timeout:30 \
725725
tests/
726726
727-
# With raw binary flags
727+
# With Lab-managed I/O policies
728+
lab run \
729+
--runtime=bin:/usr/local/bin/ferret \
730+
--policy-fs-root=./fixtures \
731+
--policy-fs-read-only \
732+
--policy-http-allowed-hosts=api.example.com \
733+
tests/
734+
735+
# With additional raw `ferret run` flags
728736
lab run \
729737
--runtime=bin:/usr/local/bin/ferret \
730-
--runtime-param='flags:["--timeout=60", "--verbose"]' \
738+
--runtime-param='flags:["--log-output=none", "--browser-headless"]' \
731739
tests/
732740
```
733741

742+
Only explicitly configured Lab policy values are forwarded, so unset values retain the external CLI's configuration and defaults. Raw policy flags remain available when the corresponding Lab policy option is unset. Lab rejects raw flags that duplicate a managed policy option or conflict with its timeout/limit counterpart.
743+
734744
#### Runtime Comparison Testing
735745

736746
Test against multiple runtime versions:
@@ -794,8 +804,8 @@ These flags apply to `lab run`.
794804
| `--timeout` | `-t` | `LAB_TIMEOUT` | `30` | Test timeout in seconds |
795805
| `--cdp` | - | `LAB_CDP` | `http://127.0.0.1:9222` | Chrome DevTools Protocol address |
796806
| `--reporter` | - | `LAB_REPORTER` | `console` | Output reporter: `console`, `simple` |
797-
| `--runtime` | `-r` | `LAB_RUNTIME` | - | URL to remote Ferret runtime |
798-
| `--runtime-param` | `--rp` | `LAB_RUNTIME_PARAM` | - | Parameters for remote runtime |
807+
| `--runtime` | `-r` | `LAB_RUNTIME` | - | Built-in, HTTP, or Ferret CLI v2 binary runtime |
808+
| `--runtime-param` | `--rp` | `LAB_RUNTIME_PARAM` | - | Runtime adapter parameters and binary raw flags |
799809
| `--concurrency` | `-c` | `LAB_CONCURRENCY` | `1` | Number of parallel test executions |
800810
| `--times` | - | `LAB_TIMES` | `1` | Number of times to run each test |
801811
| `--attempts` | `-a` | `LAB_ATTEMPTS` | `1` | Number of retry attempts for failed tests |
@@ -808,8 +818,8 @@ These flags apply to `lab run`.
808818
| `--wait` | `-w` | `LAB_WAIT` | - | Wait for resource availability |
809819
| `--wait-timeout` | `--wt` | `LAB_WAIT_TIMEOUT` | `5` | Wait timeout in seconds |
810820
| `--wait-attempts` | - | `LAB_WAIT_ATTEMPTS` | `5` | Number of wait attempts |
811-
| `--policy-fs-root` | - | `LAB_POLICY_FS_ROOT` | Current working directory | Filesystem root for the built-in runtime |
812-
| `--policy-fs-read-only` | - | `LAB_POLICY_FS_READ_ONLY` | `false` | Make the built-in runtime filesystem read-only |
821+
| `--policy-fs-root` | - | `LAB_POLICY_FS_ROOT` | Current working directory | Filesystem root for built-in and binary runtimes |
822+
| `--policy-fs-read-only` | - | `LAB_POLICY_FS_READ_ONLY` | `false` | Make built-in and binary runtime filesystems read-only |
813823
| `--policy-http-allowed-schemes` | - | `LAB_POLICY_HTTP_ALLOWED_SCHEMES` | `http,https` | Allowed outbound HTTP URL schemes |
814824
| `--policy-http-allowed-methods` | - | `LAB_POLICY_HTTP_ALLOWED_METHODS` | `GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS` | Allowed outbound HTTP methods |
815825
| `--policy-http-allowed-hosts` | - | `LAB_POLICY_HTTP_ALLOWED_HOSTS` | - | Allowed exact hosts or `host:port` values |
@@ -927,11 +937,11 @@ lab run \
927937
# Binary runtime with custom flags
928938
lab run \
929939
--runtime=bin:/usr/local/bin/ferret \
930-
--runtime-param='flags:["--timeout=60", "--verbose"]' \
940+
--runtime-param='flags:["--log-output=none", "--browser-headless"]' \
931941
tests/
932942
```
933943

934-
For HTTP runtimes, `path` overrides the run endpoint only. For binary runtimes, `flags` is special and is appended directly to the external binary argv. All other runtime params are still passed as `--param=name:value`.
944+
For HTTP runtimes, `path` overrides the run endpoint only. For binary runtimes, `flags` is special and is appended after the generated `run` subcommand. All other binary runtime params are passed as `--param=name=<JSON>`. Raw flags that conflict with managed policy options are rejected before execution.
935945

936946
## Architecture
937947

cmd/fs_policy.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@ func fsPolicyFlags(hidden bool) []cli.Flag {
1313
return []cli.Flag{
1414
&cli.StringFlag{
1515
Name: "policy-fs-root",
16-
Usage: "filesystem root directory for the built-in runtime",
16+
Usage: "filesystem root directory for built-in and binary Ferret runtimes",
1717
Sources: cli.EnvVars("LAB_POLICY_FS_ROOT"),
1818
Hidden: hidden,
1919
},
2020
&cli.BoolFlag{
2121
Name: "policy-fs-read-only",
22-
Usage: "make the built-in runtime filesystem read-only",
22+
Usage: "make built-in and binary Ferret runtime filesystems read-only",
2323
Sources: cli.EnvVars("LAB_POLICY_FS_READ_ONLY"),
2424
Hidden: hidden,
2525
},
@@ -37,9 +37,7 @@ func fsPolicyFromCommand(cmd *cli.Command) (*runtime.FileSystemPolicy, error) {
3737
return nil, nil
3838
}
3939

40-
policy := &runtime.FileSystemPolicy{
41-
ReadOnly: cmd.Bool("policy-fs-read-only"),
42-
}
40+
policy := &runtime.FileSystemPolicy{}
4341

4442
if rootSet {
4543
policy.Root = strings.TrimSpace(cmd.String("policy-fs-root"))
@@ -48,5 +46,10 @@ func fsPolicyFromCommand(cmd *cli.Command) (*runtime.FileSystemPolicy, error) {
4846
}
4947
}
5048

49+
if readOnlySet {
50+
readOnly := cmd.Bool("policy-fs-read-only")
51+
policy.ReadOnly = &readOnly
52+
}
53+
5154
return policy, nil
5255
}

cmd/fs_policy_test.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,22 @@ func TestFSPolicyFlags(t *testing.T) {
2323
t.Fatalf("expected trimmed root, got %q", policy.Root)
2424
}
2525

26-
if !policy.ReadOnly {
26+
if policy.ReadOnly == nil || !*policy.ReadOnly {
2727
t.Fatal("expected read-only policy")
2828
}
2929
}
3030

31+
func TestFSPolicyPreservesExplicitFalse(t *testing.T) {
32+
policy, err := runFSPolicyCommand(t, "--policy-fs-read-only=false")
33+
if err != nil {
34+
t.Fatalf("expected no error, got %v", err)
35+
}
36+
37+
if policy == nil || policy.ReadOnly == nil || *policy.ReadOnly {
38+
t.Fatalf("expected explicit false read-only value, got %#v", policy)
39+
}
40+
}
41+
3142
func TestFSPolicyFlagsRemainUnsetByDefault(t *testing.T) {
3243
policy, err := runFSPolicyCommand(t)
3344
if err != nil {

cmd/helpers.go

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,16 +119,22 @@ func newRuntime(cmd *cli.Command, params map[string]interface{}) (runtime.Runtim
119119
return nil, err
120120
}
121121

122-
httpPolicy, err := httpPolicyOptionsFromCommand(cmd)
122+
httpPolicy, err := httpPolicyFromCommand(cmd)
123+
if err != nil {
124+
return nil, err
125+
}
126+
127+
binaryFlags, err := extractBinaryFlags(params)
123128
if err != nil {
124129
return nil, err
125130
}
126131

127132
rt, err := runtime.New(runtime.Options{
128-
Type: cmd.String("runtime"),
129-
Params: params,
130-
FSPolicy: fsPolicy,
131-
HTTPPolicy: httpPolicy,
133+
Type: cmd.String("runtime"),
134+
Params: params,
135+
FSPolicy: fsPolicy,
136+
HTTPPolicy: httpPolicy,
137+
BinaryFlags: binaryFlags,
132138
})
133139

134140
if err != nil {
@@ -138,6 +144,43 @@ func newRuntime(cmd *cli.Command, params map[string]interface{}) (runtime.Runtim
138144
return rt, nil
139145
}
140146

147+
func extractBinaryFlags(params map[string]interface{}) ([]string, error) {
148+
value, exists := params["flags"]
149+
if !exists {
150+
return nil, nil
151+
}
152+
153+
flags, err := toStringSlice(value)
154+
if err != nil {
155+
return nil, fmt.Errorf("invalid type of flags (expected array of strings): %w", err)
156+
}
157+
158+
delete(params, "flags")
159+
160+
return flags, nil
161+
}
162+
163+
func toStringSlice(value any) ([]string, error) {
164+
switch values := value.(type) {
165+
case []string:
166+
return append([]string(nil), values...), nil
167+
case []any:
168+
result := make([]string, 0, len(values))
169+
for _, value := range values {
170+
str, ok := value.(string)
171+
if !ok {
172+
return nil, fmt.Errorf("expected string value")
173+
}
174+
175+
result = append(result, str)
176+
}
177+
178+
return result, nil
179+
default:
180+
return nil, fmt.Errorf("expected array")
181+
}
182+
}
183+
141184
func locationsFromCommand(cmd *cli.Command) ([]string, bool) {
142185
if cmd.NArg() == 0 {
143186
locations := cmd.StringSlice("files")

cmd/helpers_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package cmd
2+
3+
import (
4+
"reflect"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func TestExtractBinaryFlags(t *testing.T) {
10+
params := map[string]interface{}{
11+
"flags": []any{"--log-output=none", "--browser-headless"},
12+
"value": 1,
13+
}
14+
15+
flags, err := extractBinaryFlags(params)
16+
if err != nil {
17+
t.Fatalf("expected no error, got %v", err)
18+
}
19+
20+
if len(flags) != 2 || flags[0] != "--log-output=none" || flags[1] != "--browser-headless" {
21+
t.Fatalf("unexpected flags: %#v", flags)
22+
}
23+
if _, exists := params["flags"]; exists {
24+
t.Fatalf("expected flags to be removed from params: %#v", params)
25+
}
26+
}
27+
28+
func TestExtractBinaryFlagsRejectsInvalidValues(t *testing.T) {
29+
invalidFlags := []any{"--ok", 1}
30+
params := map[string]interface{}{
31+
"flags": invalidFlags,
32+
}
33+
34+
_, err := extractBinaryFlags(params)
35+
if err == nil || !strings.Contains(err.Error(), "invalid type of flags (expected array of strings)") {
36+
t.Fatalf("expected invalid flags error, got %v", err)
37+
}
38+
39+
if value, exists := params["flags"]; !exists || !reflect.DeepEqual(value, invalidFlags) {
40+
t.Fatalf("expected invalid flags to remain unchanged, got %#v", params)
41+
}
42+
}

0 commit comments

Comments
 (0)