Skip to content

Add a .NET SDK layer - #28

Merged
jbwinters merged 52 commits into
mainfrom
task/dotnet-sdk
Oct 29, 2026
Merged

jbwinters merged 52 commits into
mainfrom
task/dotnet-sdk

Conversation

@jbwinters

Copy link
Copy Markdown
Collaborator

Adds the .NET SDK layer on top of the .NET adapter (#27), with the same scope as the Java SDK (#26) and the Rust SDK. It has no runtime dependencies, targets .NET 8 and is tested on .NET 8 and 10. It includes:

  • Runtime.Prepare, which checks, hashes and installs sources with Core's public check, hash and run commands into a verified, owner-only cache;
  • Codecs for mapping .NET values to Jacquard values, with record, constructor, lazy (recursive) and dynamic by-name forms;
  • Operations, which bind handlers to exact operation identities with typed parameters and the handler's CancellationToken;
  • Function.Call, Invoke and MapBatches, and an Invocation with decode, render and owner-only save;
  • the four example projects (quickstart, planner, triage, fulfillment) through a .NET command, with the same --inspect, --fault and --deliver-pending recovery paths as the Rust and Java commands.

Numbers. Int is a 63-bit long and Real is a double. There is no decimal type, so money crosses as integer minor units. decimal, ulong, nuint, char, float, Half, Int128, UInt128 and BigInteger do not compile as arguments or Dynamic values, rather than being converted through double.

Core commands are bounded to 30 s, 8 MiB stdout and 4 KiB stderr, and are stopped by the token. They read through the adapter's poll-based pipes. On Linux with setsid, Core leads its own process group, which is killed when the command ends or stops, with a guard against pid reuse. Without setsid, Core is frozen with SIGSTOP on a stop, its recorded descendants are found by a capped /proc scan, and all cleanup after a stop ends within 750 ms.

Cache and save trust boundary (POSIX).

  • The cache root, its entries and files must be the user's and not group- or other-writable.
  • Every directory above the root must be owned by the user or root and not group/other-writable, unless it is sticky or writable only by the user's private group.
  • A private group is accepted only under Debian OpenSSH's rule: the group is named after the user, has no other listed members and no access ACL, and getent passwd (run as a separate, bounded process) shows no other account with it as its primary group. This keeps the default cache and work directories usable in umask-002 checkouts on Ubuntu, Fedora and Debian.
  • The root's device and inode are rechecked before staging, publishing, each load and each worker start.
  • The Core executable and prelude are checked after Core runs, and a change refuses preparation.
  • Invocation.Save applies the same directory rule, writes an exclusively created 0600 temp file through one handle, fsyncs it, checks its identity and renames it over the target.

docs/LIMITS.md records what remains: same-user replacement, accounts in name services that do not enumerate, and the windows between checks and use.

Bounded inputs.

  • Source count, path lengths, names (4,096 chars), messages and signature parsing (depth, type and token counts) are all bounded.
  • Collections are counted before copying.
  • Caller-built structure is walked iteratively, never recursively. Exceptions from caller code are wrapped in the one JacquardException with the cause kept.

Tests and CI. 252 tests pass on each runtime with live Core required, none skipped; 224 pass offline. Each guard has a regression test, shown failing on the code before its fix or by mutation; docs/EVIDENCE.md lists them. The dotnet job runs the offline suite on .NET 8, pinned to the .NET 8 SDK, and on .NET 10. live-examples runs the SDK live tests and the four projects, after checking that $RUNNER_TEMP's ancestry passes the same directory rule.

The README, examples/README.md, CONTRIBUTING, LIMITS, EVIDENCE and CHANGELOG are updated. dotnet/README.md is rewritten around the SDK and keeps the protocol-level section.

CoreCommand runs one of Core's public commands to completion on the
caller's thread, reading its stdout and stderr only when the adapter's
poll(2) reports them ready, bounded by a timeout, output limits, and a
CancellationToken. On Linux with setsid at /usr/bin or /bin the command
leads its own process group, which is killed with kill(-pgid) when the
command exits or is stopped; a reaped leader's pid held by a live process
is treated as reuse and nothing is killed. Without setsid, descendants
recorded from /proc by pid and start time are killed at the exit, and
Process.Kill(true) on a stop.

Also add the SDK's one exception type, with its 8,192-char message cap
and 64-char excerpts of caller text, Core's hash and signature parsers
with depth, type, and token bounds, and the native calls the SDK needs
(kill, geteuid, mkdir, statx or stat).
Codec<T> objects map .NET values to Jacquard values: long (Core's 63-bit
Int), int, double, string, bool, Hash, Unit, lists, Option<T> (which
nests) and nullable codecs, tuples, protocol Values, records and
enumerations and type hierarchies as constructors, and Dynamic by public
name. Records are mapped through explicit field readers and a
constructor function rather than reflection, so mapping works under
trimming and ahead-of-time compilation; Codecs.Lazy supports recursive
types. There is no decimal mapping: money crosses as integer minor units.

Every encoding is bounded as it goes by the selected limits; nesting is
checked before each nested encoder exists, raw values, decoded values,
nested unions, lazy chains, and Dynamic equality and text are walked
iteratively, and caller exceptions from codecs, field readers, and
record constructors are wrapped with the exception kept.
Runtime.Prepare checks, hashes, and installs sources with Core's public
commands into a verified, owner-only cache keyed on the source bytes, the
Core executable's bytes, every prelude file, and the protocol, with one
version check and prelude discovery per key in the process. Loading
derives every binding again from Core's cached raw output and refuses a
changed entry. Source counts, path lengths, source bytes, fingerprinting,
tree walks, and the failure cleanup are bounded and observe the token.

Operations binds typed handlers with the HandlerContext and its token,
PreparedProgram binds checked functions with complete services, and
Function calls, invokes, and maps explicit batches (each validated
first, none retried), with Invocation keeping the raw evidence.
Port the Java SDK's live suite: quickstart calls and saved inspection
files, the 64-row triage oracle in explicit batches, local refusals
before any worker, binding completeness, unknown completion with raw
evidence, the 25-case planner oracle, cache reuse and corruption, a
signature too deep to bind, a redirected name in program.json, the full
Int range and Real bits, cancellation, output overflow, unit results
kept after a later batch fails, and result-mapping failures. The README
examples under Readme/ run here too.
JacquardExamples runs quickstart, planner, triage, and fulfillment from
the rules in examples/, with the same --inspect, --fault, and
--deliver-pending recovery paths as the Rust and Java commands.
Fulfillment's warehouse commits bookings and outbox entries as one
atomic JSON snapshot, written to a temporary file it creates exclusively
and removes only if it made it and did not rename it.
Once a Core command's leader is reaped, a live process holding its pid
means the group id was reused; the group kill must then send nothing.
An unrelated group leader stands in for that process.
Document preparation and its bounds, the verified owner-only cache, codecs
and the reason for them, money as integer minor units, services, errors,
batches, the example projects, and how the SDK runs Core's commands, and
keep the protocol-level host, handler, carrier, and kit sections. The
README's four C# examples are the compiled sources under Readme/, which
DocumentTests checks in order and the live tests run.
Update the root and examples READMEs, CONTRIBUTING, LIMITS (.NET SDK rows
replace the protocol-level-only row), EVIDENCE, and the changelog. The
live CI job now runs the .NET SDK's live tests with the adapter's, and the
four example projects, with duplicate fulfillment and explicit recovery,
through the .NET command. The source archive prunes the example command's
build output, and the distribution check looks for the SDK and command.
Saving over an existing file kept that file's permissions and owner, and a
symbolic link at the target was followed. The record is now written to a
file created exclusively in the target's directory, made exactly 0600, and
renamed over the target.
…aration

The cache root was checked once and then used by its path, so another user
who may write its parent could rename it away and put their own directory
there. Its device and inode are now pinned at the check and compared again
before the staging directory is made, before publishing, and before and
after each load.

The executable and every prelude file and directory were fingerprinted
before Core ran them by path, so a replacement could make artifacts from
other bytes under the old fingerprint. Their identities (device, inode,
size, mode, owner, modification and status-change times) are now read as
they are fingerprinted and must be unchanged once Core's commands finish:
inside the version check and prelude discovery, before a cold entry is
published, and before a warm entry is loaded. The version check is keyed
on the executable's digest and identity, and a run during which anything
changed is not kept.
…roup

Without setsid, each record of the command's descendants read every
/proc entry with no cap and no stop check, and the kills when the command
exited walked every recorded process before the stop was checked. A record
now reads at most 16,384 entries, 4 MiB of stat files, and 1 KiB of each,
keeps at most 16,384 descendants, and ends at the deadline or a
cancellation, checked before each entry; the kills at the exit check them
before each descendant. On a stop, a last record bounded by the same caps
replaces .NET's tree kill, which reads every process on the machine.
A longer name was returned unconverted and then looked up, so its whole
length was hashed. Function, effect, operation, public, and constructor
names (made, dynamic, or a record codec's) over the limit are now refused
first, quoting an excerpt; an encoded constructor name is an invalid
argument, the others binding failures.
… them

C# chained a standard conversion into the SDK's own, so a ulong or nuint
became a Real through double (2^53 + 1 arrived as 2^53), a char an Int, and
a float a double. Conversions to Argument and Dynamic.Of overloads for
ulong, nuint, char, float, Half, decimal, Int128, UInt128, and BigInteger
are now obsolete as errors with the reason, so a use does not compile;
reached by reflection, the argument is refused when encoded and Of throws.
Exact overloads for the smaller integer types and uint keep them
converting to Int without ambiguity.
Lazy<T> in ExecutionAndPublication mode caches the factory's exception, so
a lazy codec whose factory failed once could never be used. The factory now
runs under a lock and only its codec is kept: a failure keeps nothing, the
next use runs it again, and once it succeeds it never runs again. A factory
that asks for its own codec is still refused.
Equality walked every path, so two separately built values that share
sub-values (a tuple holding the same tuple twice, 64 levels deep) took
exponential time. Pairs already compared are skipped.
The executable check accepted any execute bit, so a file only its group or
others may execute was resolved and then failed to start with a less clear
error. Core and setsid are now checked with access(X_OK); a regular Core
executable this user may not run is refused with a message saying so.
PreparedProgram.Function enumerated the caller's bindings dictionary while
it held the lock that Provide, Functions, and ToString take, so caller code
that blocked held them all. The count and at most one entry past the
program's effects are now read first.
Each batch's range was computed from the list's current count, so a list
that shrank after validation began made a batch of negative size. The count
is read once; a list whose count differs before a later batch is refused.
The README, LIMITS, and CHANGELOG now say how Save replaces its target,
what the cache root and Core identity checks cover and what they do not,
the bounded descendant records, the name limit, the refused numeric types,
the lazy codec retry, and the execute check; the changelog's reflection
claim is narrowed to member reflection. EVIDENCE records the new counts
and the regressions shown by undoing each fix.
…nup after it

Without setsid, a stop now sends Core SIGSTOP (guarded by its start time), makes the last record within 250 ms of the stop, kills Core and every recorded descendant within 500 ms, and waits for the exit until 750 ms. A stop during the kills at Core's own exit lets them finish within the kill budget instead of leaving the rest running.
…he root before each worker start

The cache root's path is resolved once with realpath(3); every directory above it must be owned by the user or root and not group- or world-writable unless sticky. A prepared program checks the root's device and inode before each worker starts.
…ckstart remedy; drop review chronology from the .NET evidence
… and read libc's account records only within their buffer

The private-group rule now enumerates the password database (getpwent_r,
twice, under a process-wide lock, at most 100,000 entries of 64 KiB) and
refuses the group when another account has it as its primary group, which
gr_mem does not show. A cap, a lookup error, a repeated name, a missing
user, or passes that differ count as not private. The pointers getpwuid_r,
getgrgid_r, and getpwent_r return are checked against the buffer given to
them before they are read.
…bounded and stopped by the token, instead of libc's shared getpwent position
…mits the user, or lists more than 100,000 or malformed entries
…ot writable by group or others, and run it by its checked real path
…h the test's own reading of it, private or shared
… a listing over 16 MiB or whose last line lacks a newline
…epare and per Save, and the setsid it runs through
…sing a later check for another account, user id, or group without listing again
…t alters setsid's directory for setsid's check alone
… so Prepare does not start setsid and getent each time
@jbwinters
jbwinters merged commit 3db47c1 into main Oct 29, 2026
14 checks passed
@jbwinters
jbwinters deleted the task/dotnet-sdk branch October 29, 2026 17:12
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