proto: freeze the wire contract at 1.0.0
Schemas for the whole v1 surface: 38 methods, 9 events, 25 named types and the
JSON-RPC envelope, with x-privileged / x-transports / x-deadlineMs / x-errors
annotations that both generators emit as data rather than prose.
Four generators over one IR (contracts/codegen/schema_ir.py), so the C++ structs,
the TypeScript types and the OpenRPC document cannot disagree about what the
contract says:
gen_cpp.py -> core/generated/velox_proto.{hpp,cpp}
gen_ts.py -> extension/src/shared/protocol/
gen_openrpc.py -> contracts/openrpc.json
gen_cpp_conformance.py -> tests/conformance/cpp/fixture_dispatcher.hpp
Inbound parsing never throws: parse<T>() returns std::expected<T, ParseError> and
nlohmann's throwing ADL from_json is deliberately not emitted. Schema constraints
(minimum, maxLength, pattern, ...) become real runtime checks in both languages —
the daemon does not trust the extension and the extension does not trust the
daemon.
59 golden fixtures: a success case per method, 12 error cases, 9 events. Replayed
by tests/conformance/ against both the generated C++ and a live server over both
transports. tools/mockd serves the same fixtures with unhappy-path flags so the
GUI and EXT lanes never wait for veloxd.
run.sh also proves capture.offer fails open: with a daemon answering slower than
750 ms the client gives up and lets Firefox take the download.
core/generated/ is libveloxproto, a separate target from libveloxcore, which
still never sees JSON — see docs/adr/0009.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012fgjnqFCS5h5L7gZTZo3rV
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "capture.offer \u2014 a monitored type below the minimum size is declined",
|
||||
"description": "Not an error: a normal 'no'. The daemon answers well inside the deadline and the extension lets Firefox handle it. reason is what the popup's diagnostics show.",
|
||||
"request": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 111,
|
||||
"method": "capture.offer",
|
||||
"params": {
|
||||
"url": "https://example.org/thumb.zip",
|
||||
"method": "GET",
|
||||
"tabUrl": "https://example.org/gallery",
|
||||
"contentType": "application/zip",
|
||||
"contentLength": 4096,
|
||||
"filename": "thumb.zip",
|
||||
"origin": "moz-extension://11111111-2222-3333-4444-555555555555"
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 111,
|
||||
"result": {
|
||||
"action": "ignore",
|
||||
"taskId": null,
|
||||
"reason": "below_min_size"
|
||||
}
|
||||
},
|
||||
"assertions": [
|
||||
"action ignore means the extension returns {} and Firefox downloads normally",
|
||||
"the answer still arrives within 750 ms",
|
||||
"reason is set on every ignore so a puzzled user can find out why"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "capture.offer \u2014 the daemon does not answer within 750 ms",
|
||||
"description": "The most important test in this directory. The daemon is slow, wedged or gone; the extension must abandon the offer and let Firefox download the file normally. A download manager that eats downloads when its daemon is down is worse than no download manager, and this behaviour is non-negotiable.",
|
||||
"kind": "timeout",
|
||||
"deadlineMs": 750,
|
||||
"request": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 110,
|
||||
"method": "capture.offer",
|
||||
"params": {
|
||||
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
|
||||
"method": "GET",
|
||||
"tabUrl": "https://releases.ubuntu.com/26.04/",
|
||||
"contentType": "application/octet-stream",
|
||||
"contentLength": 6228541440,
|
||||
"origin": "moz-extension://11111111-2222-3333-4444-555555555555"
|
||||
}
|
||||
},
|
||||
"response": null,
|
||||
"assertions": [
|
||||
"the extension gives up at 750 ms measured from send, not from connect",
|
||||
"webRequest returns {} so Firefox downloads the file itself",
|
||||
"no task is created, and the user sees the download in Firefox's own list",
|
||||
"the extension does not retry: a retry would race the browser's own download",
|
||||
"the same behaviour applies when the transport is disconnected entirely",
|
||||
"reproduced against mockd with --slow 2000, and by killing the daemon outright"
|
||||
],
|
||||
"requires": "a daemon that is slow, wedged or absent"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "download.add \u2014 not enough space to preallocate",
|
||||
"description": "Preallocating at the final size means the failure happens now, on add, rather than at 97 % after an hour.",
|
||||
"request": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 106,
|
||||
"method": "download.add",
|
||||
"params": {
|
||||
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
|
||||
"saveDir": "/home/sami/Downloads/Programs",
|
||||
"startMode": "now"
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 106,
|
||||
"error": {
|
||||
"code": -32012,
|
||||
"message": "not enough free space to preallocate 5.8 GB"
|
||||
}
|
||||
},
|
||||
"assertions": [
|
||||
"the check is against the actual filesystem holding saveDir, not the home directory",
|
||||
"the partially created .veloxpart is removed before this error is returned",
|
||||
"not replayable against a mock: exercised in tests/integration on a small tmpfs"
|
||||
],
|
||||
"requires": "a filesystem with no free space"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "download.add \u2014 segments above the contract's maximum",
|
||||
"description": "Params are validated against the schema before any handler runs, so a handler never sees an out-of-range value.",
|
||||
"request": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 108,
|
||||
"method": "download.add",
|
||||
"params": {
|
||||
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
|
||||
"segments": 64,
|
||||
"startMode": "now"
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 108,
|
||||
"error": {
|
||||
"code": -32602,
|
||||
"message": "params/segments: value is above the maximum of 32",
|
||||
"data": {
|
||||
"path": "params/segments"
|
||||
}
|
||||
}
|
||||
},
|
||||
"assertions": [
|
||||
"data.path is a JSON Pointer at the offending field",
|
||||
"validation happens before the handler, on both transports"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "download.add \u2014 a destination that escapes the allowed roots",
|
||||
"description": "Paths are canonicalized before the check, so ../ traversal and symlinks cannot smuggle a write outside saveTo.allowedRoots.",
|
||||
"request": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 105,
|
||||
"method": "download.add",
|
||||
"params": {
|
||||
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
|
||||
"saveDir": "/home/sami/Downloads/../../etc",
|
||||
"startMode": "now"
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 105,
|
||||
"error": {
|
||||
"code": -32011,
|
||||
"message": "destination is outside the allowed roots",
|
||||
"data": {
|
||||
"path": "/home/sami/Downloads/../../etc"
|
||||
}
|
||||
}
|
||||
},
|
||||
"assertions": [
|
||||
"the path is canonicalized first: the check is on the resolved path, never the literal string",
|
||||
"no file, no .veloxpart and no database row is created",
|
||||
"a symlink whose target escapes the roots is refused the same way"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "download.get \u2014 an unknown task id",
|
||||
"description": "The ordinary stale-client case: the GUI asks about a row another client has since removed.",
|
||||
"request": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 104,
|
||||
"method": "download.get",
|
||||
"params": {
|
||||
"taskId": "00000000-0000-4000-8000-000000000000"
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 104,
|
||||
"error": {
|
||||
"code": -32010,
|
||||
"message": "no such task",
|
||||
"data": {
|
||||
"taskId": "00000000-0000-4000-8000-000000000000"
|
||||
}
|
||||
}
|
||||
},
|
||||
"assertions": [
|
||||
"a bulk method reports this per id in failed[] instead of failing the whole call",
|
||||
"the client's correct response is to drop the row, not to retry"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "download.probe \u2014 the server answered 403",
|
||||
"description": "data.httpStatus is what lets the GUI say 'the link has expired' instead of 'probe failed'.",
|
||||
"request": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 107,
|
||||
"method": "download.probe",
|
||||
"params": {
|
||||
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso?token=expired"
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 107,
|
||||
"error": {
|
||||
"code": -32013,
|
||||
"message": "probe failed: HTTP 403",
|
||||
"data": {
|
||||
"httpStatus": 403
|
||||
}
|
||||
}
|
||||
},
|
||||
"assertions": [
|
||||
"data.httpStatus is present whenever there was an HTTP response at all",
|
||||
"a DNS or connection failure returns -32013 with httpStatus null",
|
||||
"no task is created by a failed probe",
|
||||
"not replayable against a mock: exercised in tests/integration against tools/testserver"
|
||||
],
|
||||
"requires": "an origin server that answers 403"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "an unknown method name",
|
||||
"description": "Version skew and local port-scanning both look like this. Neither gets a useful reply.",
|
||||
"request": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 109,
|
||||
"method": "download.deleteEverything",
|
||||
"params": {}
|
||||
},
|
||||
"response": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 109,
|
||||
"error": {
|
||||
"code": -32601,
|
||||
"message": "no such method"
|
||||
}
|
||||
},
|
||||
"assertions": [
|
||||
"the reply does not enumerate valid methods",
|
||||
"an unknown method never closes the connection: a newer client may simply be probing for a capability"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "session.hello \u2014 an unpaired WebSocket client is refused",
|
||||
"description": "The WS transport is reachable by any local process, so a token is mandatory there. The Unix socket needs none: SO_PEERCRED already proved same-UID.",
|
||||
"transport": "ws",
|
||||
"request": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 101,
|
||||
"method": "session.hello",
|
||||
"params": {
|
||||
"clientType": "extension",
|
||||
"clientName": "Velox for Firefox",
|
||||
"protocolVersion": "1.0.0",
|
||||
"token": "not-a-real-token"
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 101,
|
||||
"error": {
|
||||
"code": -32002,
|
||||
"message": "not paired: call session.pair first"
|
||||
}
|
||||
},
|
||||
"assertions": [
|
||||
"only session.pair is served on an unauthenticated WebSocket connection",
|
||||
"the reply does not reveal whether the token was absent, malformed or merely wrong",
|
||||
"this failure counts towards the pairing rate limit"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "session.hello \u2014 a client built against protocol 2.x is refused",
|
||||
"description": "Major mismatch fails loudly at connect rather than subtly at the tenth field. The GUI renders this as 'Velox needs updating'.",
|
||||
"request": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 100,
|
||||
"method": "session.hello",
|
||||
"params": {
|
||||
"clientType": "gui",
|
||||
"clientName": "velox-gui 9.9.9",
|
||||
"protocolVersion": "2.0.0"
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 100,
|
||||
"error": {
|
||||
"code": -32001,
|
||||
"message": "protocol major version mismatch: daemon speaks 1.x, client speaks 2.x",
|
||||
"data": {
|
||||
"expected": "1.0.0",
|
||||
"actual": "2.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"assertions": [
|
||||
"the connection is closed after this reply; no method is served on a mismatched major",
|
||||
"a differing minor or patch is accepted, never refused",
|
||||
"the message is safe to show a user verbatim",
|
||||
"the version check is transport-independent; this is replayed on the Unix socket so it is not masked by -32002"
|
||||
],
|
||||
"transport": "uds"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "session.pair \u2014 the sixth failed attempt in a minute is locked out",
|
||||
"description": "Rate limiting is what stops another local process brute-forcing its way to a token.",
|
||||
"transport": "ws",
|
||||
"request": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 102,
|
||||
"method": "session.pair",
|
||||
"params": {
|
||||
"clientName": "Velox for Firefox",
|
||||
"extensionId": "11111111-2222-3333-4444-555555555555",
|
||||
"code": "0000"
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 102,
|
||||
"error": {
|
||||
"code": -32014,
|
||||
"message": "too many pairing attempts; try again later",
|
||||
"data": {
|
||||
"retryAfterSec": 60
|
||||
}
|
||||
}
|
||||
},
|
||||
"assertions": [
|
||||
"five failures per minute, then a 60 s lockout",
|
||||
"the lockout is per-origin and survives a reconnect, or it is not a lockout",
|
||||
"no user prompt is shown while locked out \u2014 the prompt itself is the thing being flooded",
|
||||
"the runner does not brute-force a live daemon; mockd reproduces it under --lockout"
|
||||
],
|
||||
"requires": "six failed pairing attempts inside one minute"
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "settings.set \u2014 a privileged method called over the WebSocket transport",
|
||||
"description": "The extension may request a download; it may not reconfigure the daemon. Letting it write saveTo.allowedRoots would defeat every path check in the project.",
|
||||
"transport": "ws",
|
||||
"request": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 103,
|
||||
"method": "settings.set",
|
||||
"params": {
|
||||
"values": {
|
||||
"saveTo.allowedRoots": [
|
||||
"/"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 103,
|
||||
"error": {
|
||||
"code": -32003,
|
||||
"message": "method is not permitted on this transport"
|
||||
}
|
||||
},
|
||||
"assertions": [
|
||||
"the check happens before params are even parsed",
|
||||
"every method with x-privileged true behaves identically here",
|
||||
"nothing is written and no event is emitted"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user