AtlatestRepositorysigil-wire
sigil-wire / treeREADME.md
1
# sigil-wire3
A typed, length-prefixed **binary codec** for the [Sigil](https://codeberg.org/sigil/sigil)4
value model. The binary sibling to5
[sigil-json](https://codeberg.org/sigil/sigil-json): where JSON is the6
human-readable format for small control messages, sigil-wire is the compact,7
fast format for **bulk structured payloads**.9
Values decode in a single O(n) forward pass, and string/bytevector bodies are10
copied wholesale (`memcpy`), avoiding the per-character O(n²) work that JSON11
encoding of Sigil values incurs. It is the transport-neutral wire format used by12
the Slate/Lantern bulk channel and inherited by Familiar over Enclave.14
## Usage16
```scheme17
(import (sigil wire))19
;; Encode any Sigil value to a self-describing bytevector.20
(define bytes (wire-encode #{ name: "Alice" scores: #[10 20 30] }))22
;; Decode it back.23
(wire-decode bytes)24
; => #{ name: "Alice" scores: #[10 20 30] }25
```27
The decoder is a **trust boundary**: bytes may be attacker-influenced (over28
Enclave). Pass a caps record to bound the work a hostile frame can trigger:30
```scheme31
(wire-decode bytes (make-wire-caps max-depth: 32 max-count: 1000))32
```34
## API36
| Procedure | Purpose |37
|-----------|---------|38
| `(wire-encode value)` → bytevector | Serialize a Sigil value. |39
| `(wire-decode bytevector [caps])` → value | Deserialize, bounded by `caps`. |40
| `(make-wire-caps [keys])` → caps | Build a decode-limits record. |41
| `default-wire-caps` | The default caps used when none is passed. |43
### Caps (decoder limits)45
`make-wire-caps` accepts keyword arguments; any omitted field takes its default:47
| Field | Default | Meaning |48
|-------|---------|---------|49
| `max-bytes-len:` | 64 MiB | Largest single string / bytevector body. |50
| `max-count:` | 16 M | Largest collection (list/vector/array length, dict pairs). |51
| `max-depth:` | 256 | Deepest nesting of collections. Decode is recursive, so this **also bounds host stack depth** — keep it modest; cranking it to tens of thousands reintroduces stack-overflow risk on a hostile deeply-nested frame. |52
| `max-total:` | 256 MiB | Total decoded heap: string/bytevector body bytes **plus** an estimated per-element cost for every collection slot, so a collection-heavy frame of tiny elements is bounded too. |53
| `max-int-bytes:` | 1024 | Largest integer payload in varint bytes (bounds bignum size). |55
## Supported value types57
Every value the seam carries: `#f`, `#t`, exact integers (including bignums),58
doubles, strings, bytevectors, keywords, symbols, characters, lists, dicts,59
vectors (R7RS `#(...)`) and arrays (Sigil `#[...]`).61
A value that **cannot** be represented — a procedure, a port, a record without a62
registered codec, an improper (dotted) list, or a non-real number — is an63
**encode error**, never a silent drop. (Registered-tag record extensions are out64
of scope for v1.)66
## Wire format68
A message is a fixed 4-byte header followed by exactly one root value:70
```71
message : MAGIC(2) VERSION(1) FLAGS(1) <value>72
0x53 0x57 0x01 0x0074
value : TAG(1) <payload>75
```77
Decoders reject an unknown MAGIC or an unknown major VERSION. `FLAGS` is reserved78
(0). Trailing bytes after the root value are a decode error.80
Lengths and counts are unsigned **LEB128** varints (little-endian base-128; the81
high bit of each byte flags continuation). Small collections stay a single byte.83
### Tag table85
| TAG | Type | Payload |86
|------|------------|---------|87
| 0x00 | `#f` / nil | (none) |88
| 0x01 | `#t` | (none) |89
| 0x02 | int | zigzag LEB128 varint (bignum-safe, single format) |90
| 0x03 | float | 8 bytes, IEEE-754 double, little-endian |91
| 0x04 | string | varint N, then N UTF-8 bytes (memcpy) |92
| 0x05 | bytevector | varint N, then N raw bytes (memcpy) |93
| 0x06 | keyword | varint N, then N UTF-8 bytes |94
| 0x07 | symbol | varint N, then N UTF-8 bytes |95
| 0x08 | char | varint Unicode codepoint |96
| 0x09 | list | varint K, then K values |97
| 0x0A | dict | varint K, then K (key value) pairs |98
| 0x0B | vector | varint K, then K values (R7RS `#(...)`) |99
| 0x0C | array | varint K, then K values (Sigil `#[...]`) |101
Any tag outside this table is a **hard decode error** (never skip-and-continue —102
a misparsed length would cascade).104
### Integers106
A single **zigzag LEB128** varint encodes every integer, from small values up to107
arbitrary-precision bignums — there is no separate bignum form. Zigzag maps108
signed to unsigned so small negatives stay short: `n >= 0 → 2n`, `n < 0 →109
2|n| - 1`. The varint then encodes that non-negative value.111
### Floats113
Doubles are stored as their raw 64-bit IEEE-754 bit pattern, little-endian, via114
the native `bytevector-ieee-double-{ref,set!}` accessors. Every double — normal,115
subnormal, `±0.0`, `±inf`, `NaN` — round-trips **bit-exactly**, including the sign116
bit of `-0.0` (even though Sigil predicates cannot themselves distinguish `-0.0`117
from `+0.0`). On decode, the accessor canonicalizes any hostile bit pattern to a118
safe quiet-NaN flonum, so a malicious float body can never produce a119
type-confused value.121
## Security123
The decoder never trusts a length it reads:125
- Every length prefix is checked against the remaining buffer **before** any126
allocation or copy — it never pre-allocates N bytes from an untrusted varint.127
- All caps (body length, collection count, nesting depth, total decoded size,128
integer magnitude) are enforced, rejecting **before** allocating.129
- A bounds-checked cursor means a truncated or lying frame errors cleanly and130
never reads out of bounds; an endless varint is rejected rather than spun on.132
The test suite includes a hostile-input fuzz battery (truncated frames, lying133
length prefixes, cap violations, unknown tags, unknown version, varint abuse,134
invalid UTF-8 bodies) asserting each errors cleanly (or, for malformed UTF-8,135
decodes leniently to a string — never a crash or out-of-bounds read).137
### Harden before untrusted remote (Enclave) use139
One residual risk is **not** bounded by the per-frame caps: decoding a keyword or140
symbol *interns* its name permanently (the intern table is not garbage141
collected). A hostile remote peer that streams an endless supply of unique142
keyword/symbol names can exhaust memory over time. This is safe for local,143
in-process bulk payloads (the current use), but before the codec carries144
untrusted *remote* traffic over Enclave, symbol/keyword decoding should be gated145
— decoded as plain strings unless a schema explicitly opts in. That is a146
transport-level (Part 2) decision and is intentionally not enforced here, since147
gating it would break local round-trips.149
## Build151
```sh152
sigil deps install153
sigil build154
```156
## Testing158
```sh159
sigil test160
```162
## License164
BSD-3-Clause. See [LICENSE](LICENSE).