Skip to content

Let the core own the size and timestamp grammar; bindings forward text - #276

Merged
congwang-mk merged 7 commits into
mainfrom
core-owns-size-time-grammar
Oct 4, 2026
Merged

congwang-mk merged 7 commits into
mainfrom
core-owns-size-time-grammar

Conversation

@congwang-mk

Copy link
Copy Markdown
Contributor

Problem

The C ABI took max_memory/max_disk as a byte count and time_start as whole epoch seconds, so every binding whose users write "512M" or an RFC 3339 instant had to parse it first. Both SDKs did, and both drifted from the core:

  • Go and Python accepted "1.5G", "0.5K" and "1T", which no CLI flag or profile accepts.
  • Go's TimeStart took a bare epoch count (a profile refuses it) and refused a pre-1970 instant (a profile takes it).
  • Python's string time_start crashed with ValueError (the builder called int() on it), and naive datetimes were silently read as UTC.
  • The CLI and profile loader each had a private timestamp parser.
  • A pre-1970 start time collapsed to the epoch in calculate_time_offset, and the profile renderer dropped it entirely.
  • Go HTTPPorts []int and Python's c_uint16 silently wrapped port 70000 to 4464.

Fix

The core owns the grammar; bindings forward text.

  • time: compute the offset with signed, floored seconds.
  • core: one public parse_timestamp / format_timestamp next to ByteSize::parse, used by the CLI and profile; sandlock-cli drops jiff.
  • sandbox: max_memory_spec, max_disk_spec, time_start_spec builder setters. A value that does not parse is held and returned by build() naming the knob; the first rejection wins.
  • ffi: the three setters take const char *; a refused value comes back from sandlock_sandbox_build in err_msg, a null string returns a null builder.
  • go: delete go/internal/policy, forward the strings.
  • python: delete parse_memory_size, memory_bytes, time_start_timestamp; forward the strings (datetime via isoformat()).
  • bindings: Go HTTPPorts becomes []uint16; Python refuses ports outside 0 to 65535.

Breaking changes

  • C ABI: max_memory, max_disk, time_start take strings.
  • Go: TimeStart is RFC 3339 only; HTTPPorts is []uint16; MaxMemory/MaxDisk follow the core grammar.
  • Python: time_start is datetime | str (no numbers; naive datetimes refused); sizes follow the core grammar.

Not in scope

Python's cpu_pct() still clamps max_cpu itself, and other numeric fields (e.g. process limits) can still wrap at the ctypes/cgo boundary.

Testing

New Rust unit tests (time, profile round trip, builder spec setters), crates/sandlock-ffi/tests/size_time_spec.rs, Go tests for refused values and a 1969 start, Python tests for accepted/refused spellings, a 1969 run, and port range. Go suite green; 427 Python tests pass locally.

🤖 Generated with Claude Code

calculate_time_offset converted both instants with
duration_since(UNIX_EPOCH).unwrap_or_default(), which turns any instant
before the epoch into the epoch itself. The timestamp grammar accepts
such instants ("1969-07-20T20:17:00Z" parses), so the sandbox ran a
clock months away from the one requested, with no error. Floor to
signed seconds instead.

Signed-off-by: Cong Wang <cwang@multikernel.io>
The CLI and the profile loader each carried a private jiff parser for
time_start, and the profile renderer converted back through whole
non-negative seconds, so a profile naming a pre-1970 instant came back
with no time_start at all and a sub-second one came back rounded down.

Expose parse_timestamp and format_timestamp next to ByteSize::parse so
the core holds both directions of the grammar in one place, route the
CLI and profile through them, and drop the CLI's jiff dependency. A
binding that needs to read a timestamp now has a core routine to reach
instead of a reason to write its own.

Signed-off-by: Cong Wang <cwang@multikernel.io>
A binding holding "512M" or an RFC 3339 instant had nowhere in the core
to hand it: max_memory, max_disk and time_start take the parsed type,
so the binding had to parse first and ended up owning a grammar of its
own. Add max_memory_spec, max_disk_spec and time_start_spec, which read
the same grammar as the CLI and the profile.

Builder setters cannot fail, so a value that does not parse is held and
returned by build(), the same point where net and HTTP rule specs are
reported. The first rejection wins: a later valid call must not quietly
paper over a value the caller got wrong.

Signed-off-by: Cong Wang <cwang@multikernel.io>
max_memory and max_disk took a byte count and time_start whole epoch
seconds, so every binding whose users write "512M" or an RFC 3339
instant had to parse it first. Both did, and both drifted from the
core: they accepted "1.5G" and "1T", which no flag or profile accepts,
and the Go one took bare epoch counts and refused anything before 1970.

Take the text and hand it to the builder's *_spec setters, so the core
reads the one grammar and a refused value comes back from
sandlock_sandbox_build with the core's message. The epoch-seconds form
also could not carry a sub-second or pre-1970 start; the text form
carries both.

A null string frees the builder and returns null, as an out-of-range
BranchAction does: skipping it instead would build a sandbox without
the limit the caller asked for.

Signed-off-by: Cong Wang <cwang@multikernel.io>
go/internal/policy parsed MaxMemory, MaxDisk and TimeStart itself, by
rules copied from the Python SDK rather than from the core: it took
"1.5G" and "1T", which the CLI and profiles refuse, took a bare epoch
count, which a profile refuses, and refused a pre-1970 instant, which a
profile takes. The same field meant different things depending on which
surface carried it.

Now that the C ABI takes the text, pass it through unchanged and let the
build report anything the core refuses. TimeStart is RFC 3339 only.

Signed-off-by: Cong Wang <cwang@multikernel.io>
The SDK parsed max_memory and max_disk with its own regex, which took
"1.5G" and "1T" though no flag or profile does, and time_start never
worked as text at all: the builder called int() on it, so any RFC 3339
string raised ValueError. Numbers were truncated to whole seconds, and
time_start_timestamp quietly read a naive time as UTC.

Forward the values as text and let the build report what the core
refuses. time_start takes an RFC 3339 string or a datetime, which is
rendered with isoformat(); a naive datetime has no offset and is
refused by the core like any offset-less string, rather than being
pinned to a zone the caller never named. parse_memory_size,
memory_bytes and time_start_timestamp go with the grammar they carried.

Signed-off-by: Cong Wang <cwang@multikernel.io>
The C ABI carries an HTTP port as a u16. Go held HTTPPorts as []int and
converted with C.uint16_t, and Python passed through ctypes.c_uint16;
both truncate silently, so port 70000 intercepted traffic on 4464.

Make the Go field []uint16 so the compiler refuses what the ABI cannot
carry, and have Python refuse an out-of-range port before it reaches
ctypes, which offers no such check of its own.

Signed-off-by: Cong Wang <cwang@multikernel.io>
@congwang-mk
congwang-mk merged commit a0eb434 into main Oct 4, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant