@pi-in-go/pigpen-ahp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CREDITS.md +27 -0
- package/LICENSE +23 -0
- package/README.md +148 -0
- package/extensions/ahp/access_options_test.go +116 -0
- package/extensions/ahp/extension.go +54 -0
- package/extensions/ahp/go.mod +12 -0
- package/extensions/ahp/go.sum +2 -0
- package/extensions/ahp/go.work +8 -0
- package/extensions/ahp/internal/channels/chat.go +110 -0
- package/extensions/ahp/internal/channels/root.go +32 -0
- package/extensions/ahp/internal/channels/session.go +128 -0
- package/extensions/ahp/internal/channels/session_test.go +108 -0
- package/extensions/ahp/internal/compose/compose.go +153 -0
- package/extensions/ahp/internal/compose/compose_test.go +120 -0
- package/extensions/ahp/internal/gaps/gaps_test.go +88 -0
- package/extensions/ahp/internal/host/handshake_test.go +206 -0
- package/extensions/ahp/internal/host/host.go +1305 -0
- package/extensions/ahp/internal/host/reconnect_test.go +197 -0
- package/extensions/ahp/internal/host/replaywindow_test.go +31 -0
- package/extensions/ahp/internal/host/schema_test.go +79 -0
- package/extensions/ahp/internal/host/store.go +291 -0
- package/extensions/ahp/internal/host/store_update_test.go +85 -0
- package/extensions/ahp/internal/host/subscriptions_test.go +160 -0
- package/extensions/ahp/internal/host/surface_test.go +95 -0
- package/extensions/ahp/internal/host/workarounds.go +458 -0
- package/extensions/ahp/internal/host/workarounds_test.go +96 -0
- package/extensions/ahp/internal/live/live.go +328 -0
- package/extensions/ahp/internal/live/live_test.go +91 -0
- package/extensions/ahp/internal/live/store.go +113 -0
- package/extensions/ahp/internal/mapper/activity.go +199 -0
- package/extensions/ahp/internal/mapper/activity_test.go +188 -0
- package/extensions/ahp/internal/mapper/bench_test.go +38 -0
- package/extensions/ahp/internal/mapper/event_mapper_test.go +380 -0
- package/extensions/ahp/internal/mapper/fixtures_test.go +387 -0
- package/extensions/ahp/internal/mapper/helpers_test.go +251 -0
- package/extensions/ahp/internal/mapper/js.go +131 -0
- package/extensions/ahp/internal/mapper/mapper.go +600 -0
- package/extensions/ahp/internal/mapper/message_input_test.go +196 -0
- package/extensions/ahp/internal/mapper/messageinput.go +272 -0
- package/extensions/ahp/internal/mapper/schema_helpers_test.go +5 -0
- package/extensions/ahp/internal/mapper/testdata/LICENSE-pi-ahp +21 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/abort.json +711 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/bash-long-output.json +1312 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/compaction.json +6285 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/parallel-tools.json +2622 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/plain-text.json +1856 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/single-tool.json +1128 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/steering.json +3926 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-bash.json +2372 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-edit.json +3983 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-error.json +2850 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-find.json +1162 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-grep.json +2783 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-loop.json +1693 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-ls.json +2427 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-write.json +1271 -0
- package/extensions/ahp/internal/mapper/title.go +34 -0
- package/extensions/ahp/internal/mapper/usermsg.go +87 -0
- package/extensions/ahp/internal/pi/activeturn_test.go +296 -0
- package/extensions/ahp/internal/pi/backend.go +64 -0
- package/extensions/ahp/internal/pi/catalogue.go +370 -0
- package/extensions/ahp/internal/pi/catalogue_test.go +271 -0
- package/extensions/ahp/internal/pi/chatdriver.go +533 -0
- package/extensions/ahp/internal/pi/chatdriver_test.go +416 -0
- package/extensions/ahp/internal/pi/clientactions.go +212 -0
- package/extensions/ahp/internal/pi/clientactions_test.go +273 -0
- package/extensions/ahp/internal/pi/completions.go +230 -0
- package/extensions/ahp/internal/pi/completions_test.go +272 -0
- package/extensions/ahp/internal/pi/config_test.go +269 -0
- package/extensions/ahp/internal/pi/deletesession.go +69 -0
- package/extensions/ahp/internal/pi/deletesession_test.go +153 -0
- package/extensions/ahp/internal/pi/disposal_test.go +362 -0
- package/extensions/ahp/internal/pi/fixture_test.go +308 -0
- package/extensions/ahp/internal/pi/foreign_test.go +122 -0
- package/extensions/ahp/internal/pi/harness_test.go +83 -0
- package/extensions/ahp/internal/pi/history.go +269 -0
- package/extensions/ahp/internal/pi/hydrated_test.go +171 -0
- package/extensions/ahp/internal/pi/hydratedlifecycle_test.go +196 -0
- package/extensions/ahp/internal/pi/hydration_test.go +209 -0
- package/extensions/ahp/internal/pi/hydrator.go +212 -0
- package/extensions/ahp/internal/pi/imageinput.go +224 -0
- package/extensions/ahp/internal/pi/imageinput_test.go +57 -0
- package/extensions/ahp/internal/pi/lifecycle_test.go +381 -0
- package/extensions/ahp/internal/pi/models.go +212 -0
- package/extensions/ahp/internal/pi/models_test.go +128 -0
- package/extensions/ahp/internal/pi/paging.go +95 -0
- package/extensions/ahp/internal/pi/pagingtruncate_test.go +323 -0
- package/extensions/ahp/internal/pi/projecttrust.go +222 -0
- package/extensions/ahp/internal/pi/registry.go +924 -0
- package/extensions/ahp/internal/pi/restart_test.go +185 -0
- package/extensions/ahp/internal/pi/services.go +70 -0
- package/extensions/ahp/internal/pi/sessionconfig.go +64 -0
- package/extensions/ahp/internal/pi/sessionfiles_test.go +74 -0
- package/extensions/ahp/internal/pi/sessionstore.go +83 -0
- package/extensions/ahp/internal/pi/summary_test.go +426 -0
- package/extensions/ahp/internal/pi/workarounds_wire_test.go +208 -0
- package/extensions/ahp/internal/pisession/fromentries_test.go +32 -0
- package/extensions/ahp/internal/pisession/json.go +31 -0
- package/extensions/ahp/internal/pisession/pisession.go +580 -0
- package/extensions/ahp/internal/settings/access.go +63 -0
- package/extensions/ahp/internal/settings/access_test.go +52 -0
- package/extensions/ahp/internal/settings/settings.go +117 -0
- package/extensions/ahp/internal/settings/settings_test.go +104 -0
- package/extensions/ahp/internal/svc/etag_other.go +15 -0
- package/extensions/ahp/internal/svc/etag_unix.go +34 -0
- package/extensions/ahp/internal/svc/glob.go +197 -0
- package/extensions/ahp/internal/svc/mime.go +37 -0
- package/extensions/ahp/internal/svc/paths.go +108 -0
- package/extensions/ahp/internal/svc/pty.go +64 -0
- package/extensions/ahp/internal/svc/pty_darwin.go +42 -0
- package/extensions/ahp/internal/svc/pty_linux.go +36 -0
- package/extensions/ahp/internal/svc/pty_other.go +9 -0
- package/extensions/ahp/internal/svc/pty_test.go +114 -0
- package/extensions/ahp/internal/svc/pty_unix.go +147 -0
- package/extensions/ahp/internal/svc/resource.go +495 -0
- package/extensions/ahp/internal/svc/resource_test.go +501 -0
- package/extensions/ahp/internal/svc/stat_bsd.go +9 -0
- package/extensions/ahp/internal/svc/stat_linux.go +9 -0
- package/extensions/ahp/internal/svc/terminal.go +454 -0
- package/extensions/ahp/internal/svc/terminal_test.go +512 -0
- package/extensions/ahp/internal/svc/watch.go +590 -0
- package/extensions/ahp/internal/svc/watch_test.go +627 -0
- package/extensions/ahp/internal/svc/watchevents_test.go +452 -0
- package/extensions/ahp/internal/svc/watchpolicy.go +81 -0
- package/extensions/ahp/internal/svc/watchpolicy_test.go +151 -0
- package/extensions/ahp/internal/svc/watchracy_test.go +61 -0
- package/extensions/ahp/internal/testkit/schema/LICENSE-agent-host-protocol +21 -0
- package/extensions/ahp/internal/testkit/schema/actions.schema.json +9208 -0
- package/extensions/ahp/internal/testkit/schema/commands.schema.json +10862 -0
- package/extensions/ahp/internal/testkit/schema/errors.schema.json +10928 -0
- package/extensions/ahp/internal/testkit/schema/notifications.schema.json +6667 -0
- package/extensions/ahp/internal/testkit/schema/state.schema.json +6392 -0
- package/extensions/ahp/internal/testkit/schema.go +360 -0
- package/extensions/ahp/internal/testkit/testkit.go +367 -0
- package/extensions/ahp/internal/twin/twin.go +27 -0
- package/extensions/ahp/internal/wire/dispatchable.go +106 -0
- package/extensions/ahp/internal/wire/dispatchable_list.go +15 -0
- package/extensions/ahp/internal/wire/helpers_test.go +5 -0
- package/extensions/ahp/internal/wire/wire.go +218 -0
- package/extensions/ahp/internal/wire/wire_test.go +123 -0
- package/extensions/ahp/internal/ws/helpers_test.go +7 -0
- package/extensions/ahp/internal/ws/vscode_test.go +198 -0
- package/extensions/ahp/internal/ws/ws.go +674 -0
- package/extensions/ahp/internal/ws/ws_test.go +324 -0
- package/extensions/ahp/realpig_test.go +589 -0
- package/extensions/ahp/runtime.go +311 -0
- package/extensions/ahp/runtime_test.go +110 -0
- package/extensions/ahp/testdata/upstream-tests.json +3833 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/LICENSE +21 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/NOTICE-PIGPEN.md +18 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahp/client.go +1011 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahp/error.go +111 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahp/multi_host_state_mirror.go +239 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahp/reducers.go +1939 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahp/transport.go +176 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/actions.generated.go +2447 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/commands.generated.go +1546 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/common.go +205 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/errors.generated.go +65 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/messages.generated.go +138 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/notifications.generated.go +264 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/state.generated.go +6171 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/version.generated.go +31 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/go.mod +3 -0
- package/extensions/ahp/twins_test.go +91 -0
- package/package.json +40 -0
- package/proof/PORT.md +121 -0
- package/proof/mutations.json +152 -0
- package/proof/oracle/LICENSE +21 -0
- package/proof/oracle/README.md +120 -0
- package/proof/oracle/UPSTREAM.md +7 -0
- package/proof/oracle/package.json +68 -0
- package/proof/oracle/src/bin/cli.ts +66 -0
- package/proof/oracle/src/bin/tunnel.ts +128 -0
- package/proof/oracle/src/channels/chat.ts +134 -0
- package/proof/oracle/src/channels/root.ts +39 -0
- package/proof/oracle/src/channels/session.ts +118 -0
- package/proof/oracle/src/channels/terminal.ts +11 -0
- package/proof/oracle/src/core/channels.ts +108 -0
- package/proof/oracle/src/core/client-workarounds.ts +358 -0
- package/proof/oracle/src/core/connection.ts +41 -0
- package/proof/oracle/src/core/host.ts +882 -0
- package/proof/oracle/src/core/sequencer.ts +75 -0
- package/proof/oracle/src/core/state-store.ts +162 -0
- package/proof/oracle/src/core/uri.ts +25 -0
- package/proof/oracle/src/host/direct-settings.ts +110 -0
- package/proof/oracle/src/host/pi-host.ts +220 -0
- package/proof/oracle/src/host/serve.ts +71 -0
- package/proof/oracle/src/host/terminal-service.ts +346 -0
- package/proof/oracle/src/pi/activity.ts +171 -0
- package/proof/oracle/src/pi/changeset-service.ts +646 -0
- package/proof/oracle/src/pi/changeset-uri.ts +64 -0
- package/proof/oracle/src/pi/chat-driver.ts +529 -0
- package/proof/oracle/src/pi/completions.ts +160 -0
- package/proof/oracle/src/pi/delete-session.ts +53 -0
- package/proof/oracle/src/pi/event-mapper.ts +648 -0
- package/proof/oracle/src/pi/git-changes.ts +605 -0
- package/proof/oracle/src/pi/history.ts +305 -0
- package/proof/oracle/src/pi/image-input.ts +56 -0
- package/proof/oracle/src/pi/image-mime.ts +8 -0
- package/proof/oracle/src/pi/in-process-backend.ts +155 -0
- package/proof/oracle/src/pi/message-input.ts +212 -0
- package/proof/oracle/src/pi/models.ts +124 -0
- package/proof/oracle/src/pi/project-trust.ts +66 -0
- package/proof/oracle/src/pi/provider.ts +2 -0
- package/proof/oracle/src/pi/resource-paths.ts +80 -0
- package/proof/oracle/src/pi/resource-service.ts +356 -0
- package/proof/oracle/src/pi/resource-watch-policy.ts +35 -0
- package/proof/oracle/src/pi/resource-watch.ts +357 -0
- package/proof/oracle/src/pi/session-catalogue.ts +318 -0
- package/proof/oracle/src/pi/session-config.ts +84 -0
- package/proof/oracle/src/pi/session-history.ts +75 -0
- package/proof/oracle/src/pi/session-hydrator.ts +219 -0
- package/proof/oracle/src/pi/session-registry.ts +815 -0
- package/proof/oracle/src/pi/session-storage.ts +29 -0
- package/proof/oracle/src/pi/session-title.ts +18 -0
- package/proof/oracle/src/pi/turn-paging.ts +94 -0
- package/proof/oracle/src/pi/user-message.ts +66 -0
- package/proof/oracle/src/protocol/errors.ts +44 -0
- package/proof/oracle/src/protocol/jsonrpc.ts +89 -0
- package/proof/oracle/src/protocol/version.ts +39 -0
- package/proof/oracle/src/transport/websocket.ts +136 -0
- package/proof/oracle/src/tunnel/devtunnel.ts +293 -0
- package/proof/oracle/src/tunnel/discovery.ts +37 -0
- package/proof/oracle/test/active-turn-reconnect.test.ts +274 -0
- package/proof/oracle/test/activity.test.ts +171 -0
- package/proof/oracle/test/changeset-lifecycle.test.serial.ts +303 -0
- package/proof/oracle/test/changeset-uri.test.ts +37 -0
- package/proof/oracle/test/changeset.test.serial.ts +487 -0
- package/proof/oracle/test/chat-driver.test.ts +691 -0
- package/proof/oracle/test/client-actions.test.ts +409 -0
- package/proof/oracle/test/client-workarounds.test.ts +320 -0
- package/proof/oracle/test/completions.test.ts +323 -0
- package/proof/oracle/test/delete-session.test.ts +142 -0
- package/proof/oracle/test/direct-settings.test.ts +81 -0
- package/proof/oracle/test/event-mapper.test.ts +619 -0
- package/proof/oracle/test/fetch-turns.test.ts +207 -0
- package/proof/oracle/test/fixtures/abort.json +711 -0
- package/proof/oracle/test/fixtures/bash-long-output.json +1312 -0
- package/proof/oracle/test/fixtures/compaction.json +6285 -0
- package/proof/oracle/test/fixtures/parallel-tools.json +2622 -0
- package/proof/oracle/test/fixtures/plain-text.json +1856 -0
- package/proof/oracle/test/fixtures/single-tool.json +1128 -0
- package/proof/oracle/test/fixtures/steering.json +3926 -0
- package/proof/oracle/test/fixtures/tool-bash.json +2372 -0
- package/proof/oracle/test/fixtures/tool-edit.json +3983 -0
- package/proof/oracle/test/fixtures/tool-error.json +2850 -0
- package/proof/oracle/test/fixtures/tool-find.json +1162 -0
- package/proof/oracle/test/fixtures/tool-grep.json +2783 -0
- package/proof/oracle/test/fixtures/tool-loop.json +1693 -0
- package/proof/oracle/test/fixtures/tool-ls.json +2427 -0
- package/proof/oracle/test/fixtures/tool-write.json +1271 -0
- package/proof/oracle/test/handshake.test.ts +275 -0
- package/proof/oracle/test/harness.ts +153 -0
- package/proof/oracle/test/hydrated-session-lifecycle.test.ts +211 -0
- package/proof/oracle/test/image-input.test.ts +28 -0
- package/proof/oracle/test/image-session.test.ts +69 -0
- package/proof/oracle/test/live-turn.test.ts +304 -0
- package/proof/oracle/test/mapper-fixtures.test.ts +293 -0
- package/proof/oracle/test/message-input.test.ts +181 -0
- package/proof/oracle/test/model-discovery.test.ts +170 -0
- package/proof/oracle/test/models.test.ts +109 -0
- package/proof/oracle/test/pi-host.test.ts +91 -0
- package/proof/oracle/test/pi-replay.test.ts +141 -0
- package/proof/oracle/test/project-trust.test.ts +125 -0
- package/proof/oracle/test/protocol-surface.test.ts +121 -0
- package/proof/oracle/test/pty.test.ts +100 -0
- package/proof/oracle/test/reconnect.test.ts +389 -0
- package/proof/oracle/test/resource-watch-policy.test.ts +78 -0
- package/proof/oracle/test/resource-watch.test.serial.ts +536 -0
- package/proof/oracle/test/resource.test.ts +499 -0
- package/proof/oracle/test/schema.test.ts +88 -0
- package/proof/oracle/test/session-catalogue.test.ts +314 -0
- package/proof/oracle/test/session-config.test.ts +224 -0
- package/proof/oracle/test/session-disposal.test.ts +364 -0
- package/proof/oracle/test/session-hydration.test.ts +194 -0
- package/proof/oracle/test/session-lifecycle.test.ts +416 -0
- package/proof/oracle/test/session-storage.test.serial.ts +32 -0
- package/proof/oracle/test/session-summary.test.ts +418 -0
- package/proof/oracle/test/subscriptions.test.ts +198 -0
- package/proof/oracle/test/support/assertions.ts +32 -0
- package/proof/oracle/test/support/async.ts +26 -0
- package/proof/oracle/test/support/hydrated-session.ts +205 -0
- package/proof/oracle/test/support/images.ts +8 -0
- package/proof/oracle/test/support/recorded-fixtures.ts +60 -0
- package/proof/oracle/test/support/recorded-scenarios.ts +145 -0
- package/proof/oracle/test/support/replay.ts +194 -0
- package/proof/oracle/test/support/schema.ts +221 -0
- package/proof/oracle/test/support/session-files.ts +12 -0
- package/proof/oracle/test/support/session-storage.ts +22 -0
- package/proof/oracle/test/support/upstream.ts +25 -0
- package/proof/oracle/test/support/watch-events.ts +108 -0
- package/proof/oracle/test/terminal-service.test.ts +495 -0
- package/proof/oracle/test/truncate.test.ts +230 -0
- package/proof/oracle/test/tunnel.test.ts +258 -0
- package/proof/oracle/test/upstream-workarounds.test.ts +37 -0
- package/proof/oracle/test/uri.test.ts +24 -0
- package/proof/oracle/test/watch-events.test.ts +118 -0
- package/proof/oracle/tsconfig.json +32 -0
- package/proof/proof-piglet/piglet.yaml +18 -0
- package/proof/tools/fakellm/go.mod +3 -0
- package/proof/tools/fakellm/main.go +109 -0
- package/proof/tools/gen-dispatchable.py +30 -0
- package/provenance.json +28 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
package settings
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"reflect"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// Additions: the capability keys are the port's own (upstream has a bare port/token/host file).
|
|
11
|
+
|
|
12
|
+
func writeSettings(t *testing.T, body string) string {
|
|
13
|
+
t.Helper()
|
|
14
|
+
path := filepath.Join(t.TempDir(), "settings.json")
|
|
15
|
+
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
|
16
|
+
t.Fatal(err)
|
|
17
|
+
}
|
|
18
|
+
return path
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func TestAccessDefaultsToNothing(t *testing.T) {
|
|
22
|
+
// a freshly created file (port and token only) grants no capability
|
|
23
|
+
path := filepath.Join(t.TempDir(), "ahp", "settings.json")
|
|
24
|
+
if _, err := LoadDirect(path); err != nil {
|
|
25
|
+
t.Fatal(err)
|
|
26
|
+
}
|
|
27
|
+
a, err := LoadAccess(path)
|
|
28
|
+
if err != nil {
|
|
29
|
+
t.Fatal(err)
|
|
30
|
+
}
|
|
31
|
+
if a.Filesystem.Enabled || a.Terminals.Enabled || a.AllowSessionDeletion || len(a.AllowedOrigins) != 0 {
|
|
32
|
+
t.Fatalf("%+v", a)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func TestAccessReadsCapabilities(t *testing.T) {
|
|
37
|
+
a, err := LoadAccess(writeSettings(t, `{"port": 1, "allowedOrigins": ["https://vscode.dev"], "filesystem": {"enabled": true, "roots": ["/work"]}, "terminals": {"enabled": true}, "allowSessionDeletion": true}`))
|
|
38
|
+
if err != nil {
|
|
39
|
+
t.Fatal(err)
|
|
40
|
+
}
|
|
41
|
+
if !a.Filesystem.Enabled || !reflect.DeepEqual(a.Filesystem.Roots, []string{"/work"}) || a.Filesystem.Unrestricted || !a.Terminals.Enabled || !a.AllowSessionDeletion || !reflect.DeepEqual(a.AllowedOrigins, []string{"https://vscode.dev"}) {
|
|
42
|
+
t.Fatalf("%+v", a)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func TestAccessRejectsWrongTypes(t *testing.T) {
|
|
47
|
+
for _, body := range []string{`{"filesystem": true}`, `{"terminals": {"enabled": "yes"}}`, `{"allowedOrigins": "x"}`, `{"filesystem": {"roots": "/work"}}`} {
|
|
48
|
+
if _, err := LoadAccess(writeSettings(t, body)); err == nil {
|
|
49
|
+
t.Errorf("%s must be refused", body)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Package settings reads and writes the listener settings file (port of pi-ahp's
|
|
2
|
+
// src/host/direct-settings.ts).
|
|
3
|
+
//
|
|
4
|
+
// Unlike the standalone upstream server, this extension never opens a listener on its own: the
|
|
5
|
+
// file is only loaded (and, if absent, created) after the user explicitly asked for one.
|
|
6
|
+
package settings
|
|
7
|
+
|
|
8
|
+
import (
|
|
9
|
+
"crypto/rand"
|
|
10
|
+
"encoding/base64"
|
|
11
|
+
"encoding/json"
|
|
12
|
+
"fmt"
|
|
13
|
+
"math"
|
|
14
|
+
"net"
|
|
15
|
+
"os"
|
|
16
|
+
"path/filepath"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
// DefaultHost is where the listener binds unless told otherwise: loopback only.
|
|
20
|
+
const DefaultHost = "127.0.0.1"
|
|
21
|
+
|
|
22
|
+
// Direct is the endpoint configuration of the direct listener.
|
|
23
|
+
type Direct struct {
|
|
24
|
+
Port int `json:"port"`
|
|
25
|
+
Token *string `json:"token"`
|
|
26
|
+
Host string `json:"host"`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Error is a problem with the settings file, naming the file.
|
|
30
|
+
type Error struct{ Path, Detail string }
|
|
31
|
+
|
|
32
|
+
func (e *Error) Error() string { return e.Path + ": " + e.Detail }
|
|
33
|
+
|
|
34
|
+
func freePort() (int, error) {
|
|
35
|
+
l, err := net.Listen("tcp", net.JoinHostPort(DefaultHost, "0"))
|
|
36
|
+
if err != nil {
|
|
37
|
+
return 0, err
|
|
38
|
+
}
|
|
39
|
+
defer l.Close()
|
|
40
|
+
return l.Addr().(*net.TCPAddr).Port, nil
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func newToken() (string, error) {
|
|
44
|
+
var b [32]byte
|
|
45
|
+
if _, err := rand.Read(b[:]); err != nil {
|
|
46
|
+
return "", err
|
|
47
|
+
}
|
|
48
|
+
return base64.RawURLEncoding.EncodeToString(b[:]), nil
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// LoadDirect reads the settings at path. A missing file is created with a free loopback port and
|
|
52
|
+
// a fresh token (only a missing file mints a token); an existing file is taken literally, with
|
|
53
|
+
// only the optional fields defaulted, and a malformed one is an error rather than an invitation
|
|
54
|
+
// to invent replacements.
|
|
55
|
+
func LoadDirect(path string) (Direct, error) {
|
|
56
|
+
raw, err := os.ReadFile(path)
|
|
57
|
+
if os.IsNotExist(err) {
|
|
58
|
+
port, err := freePort()
|
|
59
|
+
if err != nil {
|
|
60
|
+
return Direct{}, err
|
|
61
|
+
}
|
|
62
|
+
token, err := newToken()
|
|
63
|
+
if err != nil {
|
|
64
|
+
return Direct{}, err
|
|
65
|
+
}
|
|
66
|
+
settings := Direct{Port: port, Token: &token, Host: DefaultHost}
|
|
67
|
+
return settings, write(settings, path)
|
|
68
|
+
}
|
|
69
|
+
if err != nil {
|
|
70
|
+
return Direct{}, err
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
var stored map[string]json.RawMessage
|
|
74
|
+
if err := json.Unmarshal(raw, &stored); err != nil || stored == nil {
|
|
75
|
+
detail := "not an object"
|
|
76
|
+
if err != nil {
|
|
77
|
+
detail = err.Error()
|
|
78
|
+
}
|
|
79
|
+
return Direct{}, &Error{path, fmt.Sprintf("not valid JSON (%s)", detail)}
|
|
80
|
+
}
|
|
81
|
+
rawPort, hasPort := stored["port"]
|
|
82
|
+
if !hasPort {
|
|
83
|
+
return Direct{}, &Error{path, "no `port`. Set one, or delete the file to have it chosen."}
|
|
84
|
+
}
|
|
85
|
+
var portValue float64
|
|
86
|
+
if err := json.Unmarshal(rawPort, &portValue); err != nil || portValue != math.Trunc(portValue) || portValue < 1 || portValue > 65535 {
|
|
87
|
+
return Direct{}, &Error{path, fmt.Sprintf("`port` must be an integer 1-65535, got %s", rawPort)}
|
|
88
|
+
}
|
|
89
|
+
out := Direct{Port: int(portValue), Host: DefaultHost}
|
|
90
|
+
if rawToken, ok := stored["token"]; ok && string(rawToken) != "null" {
|
|
91
|
+
var token string
|
|
92
|
+
if err := json.Unmarshal(rawToken, &token); err != nil {
|
|
93
|
+
return Direct{}, &Error{path, fmt.Sprintf("`token` must be a string or null, got %s", rawToken)}
|
|
94
|
+
}
|
|
95
|
+
out.Token = &token // absent means absent
|
|
96
|
+
}
|
|
97
|
+
if rawHost, ok := stored["host"]; ok {
|
|
98
|
+
var host string
|
|
99
|
+
if err := json.Unmarshal(rawHost, &host); err != nil {
|
|
100
|
+
return Direct{}, &Error{path, fmt.Sprintf("`host` must be a string, got %s", rawHost)}
|
|
101
|
+
}
|
|
102
|
+
out.Host = host
|
|
103
|
+
}
|
|
104
|
+
return out, nil
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
func write(s Direct, path string) error {
|
|
108
|
+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
109
|
+
return err
|
|
110
|
+
}
|
|
111
|
+
raw, err := json.MarshalIndent(s, "", " ")
|
|
112
|
+
if err != nil {
|
|
113
|
+
return err
|
|
114
|
+
}
|
|
115
|
+
// Owner-only: the token is a bearer credential for the endpoint.
|
|
116
|
+
return os.WriteFile(path, append(raw, '\n'), 0o600)
|
|
117
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
package settings_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"errors"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"reflect"
|
|
9
|
+
"regexp"
|
|
10
|
+
"runtime"
|
|
11
|
+
"testing"
|
|
12
|
+
|
|
13
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/settings"
|
|
14
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/twin"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
// Twins of upstream test/direct-settings.test.ts.
|
|
18
|
+
|
|
19
|
+
func fixture(t *testing.T) (path string, write func(any)) {
|
|
20
|
+
path = filepath.Join(t.TempDir(), "nested", "settings.json")
|
|
21
|
+
return path, func(v any) {
|
|
22
|
+
t.Helper()
|
|
23
|
+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
24
|
+
t.Fatal(err)
|
|
25
|
+
}
|
|
26
|
+
var raw []byte
|
|
27
|
+
if s, ok := v.(string); ok {
|
|
28
|
+
raw = []byte(s)
|
|
29
|
+
} else {
|
|
30
|
+
raw, _ = json.Marshal(v)
|
|
31
|
+
}
|
|
32
|
+
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
|
33
|
+
t.Fatal(err)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func TestDirectListenerSettings(t *testing.T) {
|
|
39
|
+
twin.Run(t, "direct-settings", "creates a private, reusable endpoint configuration on first use", func(t *testing.T) {
|
|
40
|
+
path, _ := fixture(t)
|
|
41
|
+
s, err := settings.LoadDirect(path)
|
|
42
|
+
if err != nil {
|
|
43
|
+
t.Fatal(err)
|
|
44
|
+
}
|
|
45
|
+
if s.Host != "127.0.0.1" || s.Port < 1 || s.Port > 65535 {
|
|
46
|
+
t.Fatalf("settings = %+v", s)
|
|
47
|
+
}
|
|
48
|
+
if s.Token == nil || !regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`).MatchString(*s.Token) {
|
|
49
|
+
t.Fatalf("token = %v", s.Token)
|
|
50
|
+
}
|
|
51
|
+
raw, _ := os.ReadFile(path)
|
|
52
|
+
var onDisk settings.Direct
|
|
53
|
+
if err := json.Unmarshal(raw, &onDisk); err != nil || !reflect.DeepEqual(onDisk, s) {
|
|
54
|
+
t.Fatalf("on disk %+v (%v), returned %+v", onDisk, err, s)
|
|
55
|
+
}
|
|
56
|
+
if runtime.GOOS != "windows" {
|
|
57
|
+
if info, _ := os.Stat(path); info.Mode().Perm() != 0o600 {
|
|
58
|
+
t.Fatalf("mode = %v; the token is a bearer credential", info.Mode().Perm())
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
again, err := settings.LoadDirect(path)
|
|
62
|
+
if err != nil || !reflect.DeepEqual(again, s) {
|
|
63
|
+
t.Fatalf("second load %+v (%v)", again, err)
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
twin.Run(t, "direct-settings", "takes an existing file literally and defaults only optional fields", func(t *testing.T) {
|
|
68
|
+
path, write := fixture(t)
|
|
69
|
+
write(map[string]any{"port": 12345})
|
|
70
|
+
got, err := settings.LoadDirect(path)
|
|
71
|
+
if err != nil || got.Port != 12345 || got.Token != nil || got.Host != "127.0.0.1" {
|
|
72
|
+
t.Fatalf("got %+v (%v)", got, err)
|
|
73
|
+
}
|
|
74
|
+
write(map[string]any{"port": 23456, "token": nil, "host": "0.0.0.0"})
|
|
75
|
+
got, err = settings.LoadDirect(path)
|
|
76
|
+
if err != nil || got.Port != 23456 || got.Token != nil || got.Host != "0.0.0.0" {
|
|
77
|
+
t.Fatalf("got %+v (%v)", got, err)
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
twin.Run(t, "direct-settings", "rejects malformed or incomplete files instead of inventing replacements", func(t *testing.T) {
|
|
82
|
+
path, write := fixture(t)
|
|
83
|
+
cases := []struct {
|
|
84
|
+
value any
|
|
85
|
+
message string
|
|
86
|
+
}{
|
|
87
|
+
{"not json", `not valid JSON`},
|
|
88
|
+
{map[string]any{}, "no `port`"},
|
|
89
|
+
{map[string]any{"port": 0}, `integer 1-65535`},
|
|
90
|
+
{map[string]any{"port": 65536}, `integer 1-65535`},
|
|
91
|
+
{map[string]any{"port": 1.5}, `integer 1-65535`},
|
|
92
|
+
{map[string]any{"port": 12345, "token": 42}, `string or null`},
|
|
93
|
+
{map[string]any{"port": 12345, "host": 42}, `host.*string`},
|
|
94
|
+
}
|
|
95
|
+
for _, c := range cases {
|
|
96
|
+
write(c.value)
|
|
97
|
+
_, err := settings.LoadDirect(path)
|
|
98
|
+
var se *settings.Error
|
|
99
|
+
if !errors.As(err, &se) || !regexp.MustCompile(c.message).MatchString(se.Error()) {
|
|
100
|
+
t.Fatalf("%v: err = %v, want /%s/", c.value, err, c.message)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//go:build !unix
|
|
2
|
+
|
|
3
|
+
package svc
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"fmt"
|
|
7
|
+
"os"
|
|
8
|
+
"time"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func etagOf(info os.FileInfo) string {
|
|
12
|
+
return fmt.Sprintf("0-0-%d-%d", info.Size(), info.ModTime().UnixNano())
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
func ctimeOf(info os.FileInfo) time.Time { return info.ModTime() }
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
//go:build unix
|
|
2
|
+
|
|
3
|
+
package svc
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"fmt"
|
|
7
|
+
"os"
|
|
8
|
+
"strconv"
|
|
9
|
+
"syscall"
|
|
10
|
+
"time"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func msOf(sec, nsec int64) string {
|
|
14
|
+
return strconv.FormatFloat(float64(sec)*1e3+float64(nsec)/1e6, 'f', -1, 64)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// etagOf is an opaque change token: device, inode, size and both timestamps, so a write, a
|
|
18
|
+
// replacement or a metadata change all invalidate it.
|
|
19
|
+
func etagOf(info os.FileInfo) string {
|
|
20
|
+
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
|
21
|
+
mt, ct := statTimes(st)
|
|
22
|
+
return fmt.Sprintf("%d-%d-%d-%s-%s", st.Dev, st.Ino, st.Size, msOf(mt[0], mt[1]), msOf(ct[0], ct[1]))
|
|
23
|
+
}
|
|
24
|
+
return fmt.Sprintf("0-0-%d-%d", info.Size(), info.ModTime().UnixNano())
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ctimeOf is the inode change time (the birth time is not portable).
|
|
28
|
+
func ctimeOf(info os.FileInfo) time.Time {
|
|
29
|
+
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
|
30
|
+
_, ct := statTimes(st)
|
|
31
|
+
return time.Unix(ct[0], ct[1])
|
|
32
|
+
}
|
|
33
|
+
return info.ModTime()
|
|
34
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
package svc
|
|
2
|
+
|
|
3
|
+
import "strings"
|
|
4
|
+
|
|
5
|
+
// matchesGlob reports whether a slash-separated relative path matches a glob pattern with the
|
|
6
|
+
// semantics of Node's path.matchesGlob (minimatch): `*` and `?` stay inside one segment, `**`
|
|
7
|
+
// spans whole segments, `[...]` is a character class, `{a,b}` alternates, and wildcards never
|
|
8
|
+
// match a segment starting with a dot unless the pattern names the dot itself. A trailing `**`
|
|
9
|
+
// needs at least one segment (so `a/**` does not match `a`).
|
|
10
|
+
func matchesGlob(path, pattern string) bool {
|
|
11
|
+
segments := strings.Split(path, "/")
|
|
12
|
+
for _, alt := range expandBraces(pattern) {
|
|
13
|
+
if matchSegments(strings.Split(alt, "/"), segments) {
|
|
14
|
+
return true
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return false
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func expandBraces(pattern string) []string {
|
|
21
|
+
open := -1
|
|
22
|
+
depth := 0
|
|
23
|
+
for i := 0; i < len(pattern); i++ {
|
|
24
|
+
switch pattern[i] {
|
|
25
|
+
case '\\':
|
|
26
|
+
i++
|
|
27
|
+
case '{':
|
|
28
|
+
if depth == 0 {
|
|
29
|
+
open = i
|
|
30
|
+
}
|
|
31
|
+
depth++
|
|
32
|
+
case '}':
|
|
33
|
+
if depth == 0 {
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
depth--
|
|
37
|
+
if depth == 0 {
|
|
38
|
+
body := pattern[open+1 : i]
|
|
39
|
+
var parts []string
|
|
40
|
+
level, start := 0, 0
|
|
41
|
+
for j := 0; j < len(body); j++ {
|
|
42
|
+
switch body[j] {
|
|
43
|
+
case '\\':
|
|
44
|
+
j++
|
|
45
|
+
case '{':
|
|
46
|
+
level++
|
|
47
|
+
case '}':
|
|
48
|
+
level--
|
|
49
|
+
case ',':
|
|
50
|
+
if level == 0 {
|
|
51
|
+
parts = append(parts, body[start:j])
|
|
52
|
+
start = j + 1
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
parts = append(parts, body[start:])
|
|
57
|
+
if len(parts) == 1 {
|
|
58
|
+
// {a} is literal in minimatch, like a brace without a comma.
|
|
59
|
+
return []string{pattern}
|
|
60
|
+
}
|
|
61
|
+
var out []string
|
|
62
|
+
for _, p := range parts {
|
|
63
|
+
out = append(out, expandBraces(pattern[:open]+p+pattern[i+1:])...)
|
|
64
|
+
}
|
|
65
|
+
return out
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return []string{pattern}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func matchSegments(pattern, path []string) bool {
|
|
73
|
+
if len(pattern) == 0 {
|
|
74
|
+
return len(path) == 0
|
|
75
|
+
}
|
|
76
|
+
if pattern[0] == "**" {
|
|
77
|
+
if len(pattern) == 1 {
|
|
78
|
+
if len(path) == 0 {
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
for _, s := range path {
|
|
82
|
+
if strings.HasPrefix(s, ".") {
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
for skip := 0; skip <= len(path); skip++ {
|
|
89
|
+
if matchSegments(pattern[1:], path[skip:]) {
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
if skip < len(path) && strings.HasPrefix(path[skip], ".") {
|
|
93
|
+
break // ** never swallows a dot segment
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return false
|
|
97
|
+
}
|
|
98
|
+
if len(path) == 0 {
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
if strings.HasPrefix(path[0], ".") && !strings.HasPrefix(pattern[0], ".") {
|
|
102
|
+
return false
|
|
103
|
+
}
|
|
104
|
+
return matchSegment(pattern[0], path[0]) && matchSegments(pattern[1:], path[1:])
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
func matchSegment(pattern, name string) bool {
|
|
108
|
+
return matchRunes([]rune(pattern), []rune(name))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func matchRunes(p, s []rune) bool {
|
|
112
|
+
for len(p) > 0 {
|
|
113
|
+
switch p[0] {
|
|
114
|
+
case '*':
|
|
115
|
+
for len(p) > 0 && p[0] == '*' {
|
|
116
|
+
p = p[1:]
|
|
117
|
+
}
|
|
118
|
+
if len(p) == 0 {
|
|
119
|
+
return true
|
|
120
|
+
}
|
|
121
|
+
for i := 0; i <= len(s); i++ {
|
|
122
|
+
if matchRunes(p, s[i:]) {
|
|
123
|
+
return true
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return false
|
|
127
|
+
case '?':
|
|
128
|
+
if len(s) == 0 {
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
p, s = p[1:], s[1:]
|
|
132
|
+
case '[':
|
|
133
|
+
if len(s) == 0 {
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
ok, rest, valid := matchClass(p, s[0])
|
|
137
|
+
if !valid {
|
|
138
|
+
// An unterminated class is a literal '['.
|
|
139
|
+
if s[0] != '[' {
|
|
140
|
+
return false
|
|
141
|
+
}
|
|
142
|
+
p, s = p[1:], s[1:]
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
if !ok {
|
|
146
|
+
return false
|
|
147
|
+
}
|
|
148
|
+
p, s = rest, s[1:]
|
|
149
|
+
case '\\':
|
|
150
|
+
if len(p) > 1 {
|
|
151
|
+
p = p[1:]
|
|
152
|
+
}
|
|
153
|
+
fallthrough
|
|
154
|
+
default:
|
|
155
|
+
if len(s) == 0 || s[0] != p[0] {
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
p, s = p[1:], s[1:]
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return len(s) == 0
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// matchClass matches r against the class at the start of p; valid is false when it never closes.
|
|
165
|
+
func matchClass(p []rune, r rune) (ok bool, rest []rune, valid bool) {
|
|
166
|
+
i := 1
|
|
167
|
+
negate := false
|
|
168
|
+
if i < len(p) && (p[i] == '!' || p[i] == '^') {
|
|
169
|
+
negate = true
|
|
170
|
+
i++
|
|
171
|
+
}
|
|
172
|
+
first := true
|
|
173
|
+
matched := false
|
|
174
|
+
for ; i < len(p); i++ {
|
|
175
|
+
if p[i] == ']' && !first {
|
|
176
|
+
return matched != negate, p[i+1:], true
|
|
177
|
+
}
|
|
178
|
+
first = false
|
|
179
|
+
lo := p[i]
|
|
180
|
+
if p[i] == '\\' && i+1 < len(p) {
|
|
181
|
+
i++
|
|
182
|
+
lo = p[i]
|
|
183
|
+
}
|
|
184
|
+
if i+2 < len(p) && p[i+1] == '-' && p[i+2] != ']' {
|
|
185
|
+
hi := p[i+2]
|
|
186
|
+
if lo <= r && r <= hi {
|
|
187
|
+
matched = true
|
|
188
|
+
}
|
|
189
|
+
i += 2
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
if r == lo {
|
|
193
|
+
matched = true
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return false, nil, false
|
|
197
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
package svc
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"path/filepath"
|
|
5
|
+
"strings"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
// mimeTypes is the subset of the `mime` npm package's table (mime-db) that matters for source
|
|
9
|
+
// trees: the same answers for the extensions people actually open. An extension that is not here
|
|
10
|
+
// has no content type (text files fall back to text/plain at the call site).
|
|
11
|
+
var mimeTypes = map[string]string{
|
|
12
|
+
"txt": "text/plain", "text": "text/plain", "log": "text/plain", "conf": "text/plain", "ini": "text/plain",
|
|
13
|
+
"md": "text/markdown", "markdown": "text/markdown", "html": "text/html", "htm": "text/html", "css": "text/css",
|
|
14
|
+
"csv": "text/csv", "tsv": "text/tab-separated-values", "xml": "application/xml", "json": "application/json",
|
|
15
|
+
"map": "application/json", "yaml": "text/yaml", "yml": "text/yaml", "toml": "application/toml",
|
|
16
|
+
"js": "text/javascript", "mjs": "text/javascript", "cjs": "text/javascript", "jsx": "text/jsx", "ts": "video/mp2t",
|
|
17
|
+
"tsx": "text/jsx", "sh": "application/x-sh", "py": "text/x-python", "c": "text/x-c", "h": "text/x-c", "cc": "text/x-c",
|
|
18
|
+
"cpp": "text/x-c", "java": "text/x-java-source", "rs": "text/x-rust", "go": "text/x-go", "rb": "text/x-ruby",
|
|
19
|
+
"php": "application/x-httpd-php", "sql": "application/sql", "svg": "image/svg+xml", "png": "image/png",
|
|
20
|
+
"jpg": "image/jpeg", "jpeg": "image/jpeg", "gif": "image/gif", "webp": "image/webp", "bmp": "image/bmp",
|
|
21
|
+
"ico": "image/vnd.microsoft.icon", "tif": "image/tiff", "tiff": "image/tiff", "avif": "image/avif",
|
|
22
|
+
"pdf": "application/pdf", "zip": "application/zip", "gz": "application/gzip", "tar": "application/x-tar",
|
|
23
|
+
"wasm": "application/wasm", "mp3": "audio/mpeg", "wav": "audio/wav", "mp4": "video/mp4", "webm": "video/webm",
|
|
24
|
+
"woff": "font/woff", "woff2": "font/woff2", "ttf": "font/ttf", "otf": "font/otf",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// contentTypeOf is the content type for a path's extension; "" when unknown and no text fallback.
|
|
28
|
+
func contentTypeOf(path string, textFallback bool) string {
|
|
29
|
+
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(path), "."))
|
|
30
|
+
if t, ok := mimeTypes[ext]; ok {
|
|
31
|
+
return t
|
|
32
|
+
}
|
|
33
|
+
if textFallback {
|
|
34
|
+
return "text/plain"
|
|
35
|
+
}
|
|
36
|
+
return ""
|
|
37
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Package svc holds the services that expose the machine to a remote client: file resources,
|
|
2
|
+
// resource watches and terminals. Every one is opt-in and confined; nothing here runs unless the
|
|
3
|
+
// extension was explicitly configured to enable it (port of pi-ahp's resource-*, terminal
|
|
4
|
+
// services).
|
|
5
|
+
package svc
|
|
6
|
+
|
|
7
|
+
import (
|
|
8
|
+
"errors"
|
|
9
|
+
"fmt"
|
|
10
|
+
"io/fs"
|
|
11
|
+
"os"
|
|
12
|
+
"path/filepath"
|
|
13
|
+
"strings"
|
|
14
|
+
|
|
15
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/wire"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
// canonicalPath resolves symlinks in path even when the path (or its tail) does not exist yet: a
|
|
19
|
+
// dangling symlink is followed to where it would create the file, so a write through a link that
|
|
20
|
+
// points outside the permitted roots cannot slip past the check.
|
|
21
|
+
func canonicalPath(path string) (string, error) {
|
|
22
|
+
absolute, err := filepath.Abs(path)
|
|
23
|
+
if err != nil {
|
|
24
|
+
return "", err
|
|
25
|
+
}
|
|
26
|
+
if real, err := filepath.EvalSymlinks(absolute); err == nil {
|
|
27
|
+
return real, nil
|
|
28
|
+
} else if !errors.Is(err, fs.ErrNotExist) {
|
|
29
|
+
return "", err
|
|
30
|
+
}
|
|
31
|
+
if info, err := os.Lstat(absolute); err == nil {
|
|
32
|
+
if info.Mode()&fs.ModeSymlink != 0 {
|
|
33
|
+
target, err := os.Readlink(absolute)
|
|
34
|
+
if err != nil {
|
|
35
|
+
return "", err
|
|
36
|
+
}
|
|
37
|
+
if !filepath.IsAbs(target) {
|
|
38
|
+
target = filepath.Join(filepath.Dir(absolute), target)
|
|
39
|
+
}
|
|
40
|
+
return canonicalPath(target)
|
|
41
|
+
}
|
|
42
|
+
} else if !errors.Is(err, fs.ErrNotExist) {
|
|
43
|
+
return "", err
|
|
44
|
+
}
|
|
45
|
+
parent := filepath.Dir(absolute)
|
|
46
|
+
if parent == absolute {
|
|
47
|
+
return absolute, nil
|
|
48
|
+
}
|
|
49
|
+
canonicalParent, err := canonicalPath(parent)
|
|
50
|
+
if err != nil {
|
|
51
|
+
return "", err
|
|
52
|
+
}
|
|
53
|
+
return filepath.Join(canonicalParent, filepath.Base(absolute)), nil
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// PathPolicy turns file: URIs into local paths and, when roots are configured, refuses anything
|
|
57
|
+
// that resolves outside them. With no roots every path is allowed; the extension never builds a
|
|
58
|
+
// policy without roots unless the user explicitly asked for an unrestricted one.
|
|
59
|
+
type PathPolicy struct{ roots []string }
|
|
60
|
+
|
|
61
|
+
// NewPathPolicy creates a policy over roots (each must exist; they are canonicalised).
|
|
62
|
+
func NewPathPolicy(roots ...string) (*PathPolicy, error) {
|
|
63
|
+
p := &PathPolicy{}
|
|
64
|
+
for _, root := range roots {
|
|
65
|
+
abs, err := filepath.Abs(root)
|
|
66
|
+
if err != nil {
|
|
67
|
+
return nil, err
|
|
68
|
+
}
|
|
69
|
+
real, err := filepath.EvalSymlinks(abs)
|
|
70
|
+
if err != nil {
|
|
71
|
+
return nil, fmt.Errorf("resource root %s: %w", root, err)
|
|
72
|
+
}
|
|
73
|
+
p.roots = append(p.roots, real)
|
|
74
|
+
}
|
|
75
|
+
return p, nil
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Restricted reports whether the policy confines paths to roots.
|
|
79
|
+
func (p *PathPolicy) Restricted() bool { return len(p.roots) > 0 }
|
|
80
|
+
|
|
81
|
+
// PathFor returns the local path of a file: URI, or a protocol error: InvalidParams for a wrong
|
|
82
|
+
// scheme or malformed URI, PermissionDenied for a path outside the roots.
|
|
83
|
+
func (p *PathPolicy) PathFor(uri string) (string, error) {
|
|
84
|
+
if !strings.HasPrefix(uri, "file://") {
|
|
85
|
+
return "", wire.InvalidParams("Only file: URIs are supported, got: " + uri)
|
|
86
|
+
}
|
|
87
|
+
path, err := wire.FileURIToPath(uri)
|
|
88
|
+
if err != nil {
|
|
89
|
+
return "", wire.InvalidParams("Malformed file URI: " + uri)
|
|
90
|
+
}
|
|
91
|
+
if len(p.roots) == 0 {
|
|
92
|
+
return path, nil
|
|
93
|
+
}
|
|
94
|
+
candidate, err := canonicalPath(path)
|
|
95
|
+
if err != nil {
|
|
96
|
+
return "", wire.Coded(wire.CodePermissionDenied, err.Error())
|
|
97
|
+
}
|
|
98
|
+
for _, root := range p.roots {
|
|
99
|
+
prefix := root
|
|
100
|
+
if !strings.HasSuffix(prefix, string(filepath.Separator)) {
|
|
101
|
+
prefix += string(filepath.Separator)
|
|
102
|
+
}
|
|
103
|
+
if candidate == root || strings.HasPrefix(candidate, prefix) {
|
|
104
|
+
return path, nil
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return "", wire.Coded(wire.CodePermissionDenied, "Outside the permitted roots: "+uri)
|
|
108
|
+
}
|