Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
c3d19d1
Add bounded Core commands for the .NET SDK
jbwinters Oct 29, 2026
5f2ad2d
Add .NET SDK data mapping
jbwinters Oct 29, 2026
b668519
Add .NET SDK preparation, services, and invocations
jbwinters Oct 29, 2026
7e75869
Add live .NET SDK tests
jbwinters Oct 29, 2026
4a09574
Run the four example projects through the .NET SDK
jbwinters Oct 29, 2026
1219896
Test that a reused group id is not killed
jbwinters Oct 29, 2026
9ee0e10
Rewrite the .NET README around the SDK
jbwinters Oct 29, 2026
4f6348a
Document the .NET SDK and run it in CI
jbwinters Oct 29, 2026
7569323
Save an invocation through a new owner-only file renamed over the target
jbwinters Oct 29, 2026
2600ef5
Refuse a cache root, Core executable, or prelude replaced during prep…
jbwinters Oct 29, 2026
ce8a203
Bound the descendant records made where Core runs without a process g…
jbwinters Oct 29, 2026
881e797
Refuse caller names over 4,096 chars before they are looked up
jbwinters Oct 29, 2026
3d50e6b
Refuse numeric types that have no Jacquard type instead of converting…
jbwinters Oct 29, 2026
323d6eb
Run a lazy codec's factory again after it throws
jbwinters Oct 29, 2026
76a7582
Compare each pair of shared Dynamic sub-values once
jbwinters Oct 29, 2026
c7dba6e
Check that this user may execute Core with access(2)
jbwinters Oct 29, 2026
d1eb376
Read a function's bindings before taking the program's lock
jbwinters Oct 29, 2026
3a133b3
Read the MapBatches row count once
jbwinters Oct 29, 2026
96c0fc7
Document the review fixes in the .NET SDK docs
jbwinters Oct 29, 2026
985338d
Limit each /proc stat read to the bytes left in the record's cap
jbwinters Oct 29, 2026
6747d0f
Freeze Core before the last /proc record on a stop and bound the clea…
jbwinters Oct 29, 2026
c614431
Refuse a cache root under a directory others may write, and recheck t…
jbwinters Oct 29, 2026
07bc7b5
Make Dynamic equality's pair set only when it meets a shared sub-value
jbwinters Oct 29, 2026
b6ff6b4
Wrap exceptions from the caller's source list and bindings with their…
jbwinters Oct 29, 2026
ae9e294
Make each missing level of a cache root owner-only
jbwinters Oct 29, 2026
8050bef
Document the second review round's .NET SDK fixes
jbwinters Oct 29, 2026
16ab90c
Measure the cleanup budgets after a stop on the test clock instead of…
jbwinters Oct 29, 2026
0540b78
Accept a cache parent writable only by the user's private group, and …
jbwinters Oct 29, 2026
8f48811
Check a cache root's existing parents before making its missing levels
jbwinters Oct 29, 2026
baa841e
Refuse a save directory others may write, and check the temporary fil…
jbwinters Oct 29, 2026
fc19966
Check RUNNER_TEMP's ancestry before the .NET example projects run in CI
jbwinters Oct 29, 2026
5d493dc
Refuse a private-group directory that has an access ACL
jbwinters Oct 29, 2026
be6a7ee
Document the private-group rule, the save directory rule, and the qui…
jbwinters Oct 29, 2026
901a3fc
Refuse a private group that another account has as its primary group,…
jbwinters Oct 29, 2026
7fafdcd
Remove Save's temporary file when its handle's status cannot be read
jbwinters Oct 29, 2026
ef7210b
Make the SDK tests' scratch directories where no group or other user …
jbwinters Oct 29, 2026
4cdbce2
Accept the private group in CI's RUNNER_TEMP check, by the SDK's rule
jbwinters Oct 29, 2026
5cb010a
Document the password database scan of the private-group rule, its li…
jbwinters Oct 29, 2026
37f1fe4
Treat an account, group, or member name that is not valid UTF-8 as a …
jbwinters Oct 29, 2026
d46355d
List the password database with getent passwd in a separate process, …
jbwinters Oct 29, 2026
4dbf9f9
Test that getent is refused when its file or a directory above it is …
jbwinters Oct 29, 2026
8cc4b5f
Fail CI's RUNNER_TEMP private-group check closed when getent fails, o…
jbwinters Oct 29, 2026
03aeef0
Document the getent passwd listing of the private-group rule, its bou…
jbwinters Oct 29, 2026
3c382ba
List the password database at most once per preparation, sharing the …
jbwinters Oct 29, 2026
52904be
Require the setsid that getent passwd runs through to be root's and n…
jbwinters Oct 29, 2026
ba863fc
Compare the SDK's verdict on this machine's getent passwd listing wit…
jbwinters Oct 29, 2026
17277e7
Capture getent passwd to a file in CI's private-group check, refusing…
jbwinters Oct 29, 2026
746d161
Document the password database listing's real time bound, once per Pr…
jbwinters Oct 29, 2026
e6e321e
List the password database at most once per preparation or save, refu…
jbwinters Oct 29, 2026
cfa38dc
Give the getent status seam the file being checked, so the setsid tes…
jbwinters Oct 29, 2026
42a4233
Suggest owner-only cache parents to callers that prepare per request,…
jbwinters Oct 29, 2026
7fa0fcd
Record the account-change listing test, the setsid directory isolatio…
jbwinters Oct 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 72 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -213,10 +213,81 @@ jobs:
dotnet-version: |
8.0.x
10.0.x
- name: Run independent .NET adapter against Core
- name: Run independent .NET adapter and SDK against Core
run: |
dotnet test test/JacquardHost.Tests -f net8.0
dotnet test test/JacquardHost.Tests -f net10.0
working-directory: dotnet
env:
JACQUARD_REQUIRE_LIVE: "1"
- name: Run the four example projects through the .NET SDK
working-directory: dotnet
run: |
set -euo pipefail
# The SDK refuses a program cache under a directory another user
# could write; check $RUNNER_TEMP's ancestry here by the same rule,
# so a runner set up otherwise fails clearly rather than inside the
# SDK. A group-writable level passes only when its group is the
# user's private group: the user's primary group, named as the user,
# no other listed member, and no access ACL; and getent passwd
# succeeds, prints at most 16 MiB ending in a newline, lists at most
# 100,000 well-formed entries with no name twice, lists the user
# with that primary group, and lists no account of another uid
# with it.
me=$(id -un); uid=$(id -u); gid=$(id -g)
private=no
listing=$(mktemp)
trap 'rm -f "$listing"' EXIT
passwd_status=0
# At most one byte over the cap is kept; a longer listing fails (getent is stopped) and is refused.
getent passwd | head -c 16777217 > "$listing" || passwd_status=$?
size=$(wc -c < "$listing")
group_status=0
group_entry=$(getent group "$gid") || group_status=$?
if [ "$passwd_status" = 0 ] && [ "$group_status" = 0 ] \
&& [ "$size" -gt 0 ] && [ "$size" -le 16777216 ] \
&& [ "$(tail -c 1 "$listing" | od -An -tx1 | tr -d ' \n')" = 0a ] \
&& awk -F: -v n="$me" -v u="$uid" -v g="$gid" '
NF != 7 || $1 == "" || $3 !~ /^(0|[1-9][0-9]*)$/ || $4 !~ /^(0|[1-9][0-9]*)$/ || seen[$1]++ { bad = 1; exit }
$1 == n { if ($3 != u || $4 != g) { bad = 1; exit }; listed = 1; next }
$4 == g && $3 != u { bad = 1; exit }
END { exit (bad || !listed || NR > 100000) }' "$listing" \
&& [ "$(printf '%s\n' "$group_entry" | cut -d: -f1)" = "$me" ] \
&& [ -z "$(printf '%s\n' "$group_entry" | cut -d: -f4 | tr , '\n' | grep -vx "$me")" ]; then
private=yes
fi
rm -f "$listing"
d=$(realpath "$RUNNER_TEMP")
while :; do
mode=$(stat -c %a "$d"); owner=$(stat -c %u "$d"); group=$(stat -c %g "$d")
bad=
if [ "$owner" != "$uid" ] && [ "$owner" != 0 ]; then
bad="owned by another user"
elif [ $((0$mode & 01000)) = 0 ] && [ $((0$mode & 02)) != 0 ]; then
bad="writable by others"
elif [ $((0$mode & 01000)) = 0 ] && [ $((0$mode & 020)) != 0 ]; then
if [ "$group" != "$gid" ] || [ "$private" != yes ]; then
bad="writable by its group, which is not your private group"
elif ls -ld "$d" | cut -c11 | grep -q '+'; then
bad="writable by its group, with an access control list"
fi
fi
if [ -n "$bad" ]; then
echo "::error::$d (mode $mode, owner $owner, group $group) is $bad; the .NET SDK refuses a cache under it" >&2
exit 1
fi
[ "$d" = / ] && break
d=$(dirname "$d")
done
dotnet build examples/JacquardExamples
run() { dotnet examples/JacquardExamples/bin/Debug/net8.0/JacquardExamples.dll --workdir "$RUNNER_TEMP/dotnet-examples" "$@"; }
run quickstart
run planner | grep -q '"score": 16'
run triage | grep -q '"urgency": "WATCH"'
run fulfillment | grep -q '"$tag": "Fulfilled"'
run fulfillment | grep -q '"$tag": "DuplicateOrder"'
status=0
run --workdir "$RUNNER_TEMP/dotnet-recovery" --fault publish-failed fulfillment || status=$?
test "$status" = 1
run --workdir "$RUNNER_TEMP/dotnet-recovery" --deliver-pending fulfillment | grep -q '{"delivered":1}'
run --workdir "$RUNNER_TEMP/dotnet-recovery" --deliver-pending fulfillment | grep -q '{"delivered":0}'
45 changes: 43 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,7 @@
codecs that parse integers exactly from their text, the session state
machine, a bounded process carrier for `jacquard host worker`, and a `Host`
with a closed operation registry. It carries the pinned kit as embedded
resources and replays all 21 recordings. It is protocol-level only, with no
SDK layer. .NET cannot start the worker in its own process group or
resources and replays all 21 recordings. .NET cannot start the worker in its own process group or
interrupt a pipe read or write, so the carrier never calls one that can
block: it waits on the C library's `poll(2)` and runs workers on Linux and
macOS only. It kills the worker's process tree with `Process.Kill(true)`,
Expand All @@ -88,6 +87,48 @@
`scripts/core_pin.py` now generates `dotnet/src/JacquardHost/Compat.cs` and
keeps the .NET kit copy in sync, and CI runs the offline tests on .NET 8 and
10 and the live tests against the pinned Core.
- .NET SDK (`JacquardLang.Host.Sdk`): prepares and caches checked programs
with Core's public commands, maps .NET values through explicit `Codec<T>`
objects (records through field readers and a constructor function,
enumerations, type hierarchies, `Option<T>`, `Unit`, lazy codecs for
recursive types, and `Dynamic` for untyped code) without reflection over
members (enumeration codecs read `Enum` names and values, and hierarchy
codecs check case types with `Type`), binds
handlers to exact operation identities with the `HandlerContext` and its
`CancellationToken`, checks arguments against the checked signature before a
worker starts, and keeps each invocation's raw evidence. `MapBatches` runs
explicit batches. There is no `decimal` mapping: money crosses as integer
minor units, and `decimal`, `ulong`, `nuint`, `char`, `float`, and the
other numeric types with no Jacquard type do not compile as arguments.
Caller names over 4,096 chars are refused before they are looked up.
Core's commands run on the caller's thread through `poll(2)`, bounded in
time and output, and on Linux through `setsid`, whose process group is
killed when a command exits or is stopped; without `setsid`, descendants
are recorded from `/proc` within entry and byte caps, and on a stop Core
is frozen with `SIGSTOP` before the last record and the cleanup ends
within 750 ms. Only the cache's owner may write it: the root, each entry,
and each file must be owned by the current user and not group- or
world-writable, every directory above the root must be owned by the user
or root and not group- or world-writable unless it is sticky or the group
is the user's private group (Debian OpenSSH's rule, which looks for
another account with that primary group in the password database, listed
here by `getent passwd` in a separate process, with it and the `setsid`
it runs through root's alone, at most once per preparation or save,
limited to 5 seconds (then stopped within 750 ms) and 16 MiB, stopped by
the token, and refused on any failure or malformed entry; with stricter conditions, so a clone made with a `umask` of 002
under user-private groups is accepted), a refusal names
every such directory and creates nothing, and the root's device and inode
are checked again before each use, each worker a prepared program starts
included. Replacement by the same user is not covered.
Every binding is derived again from Core's cached output on load, and a
preparation during which the Core executable or prelude changed is
refused. `Invocation.Save` refuses a target directory another user could
write, by the same directory rule, writes a new owner-only file through one
exclusively created handle, flushes it, checks the file at its path is the
one written, and renames it over the target.
The four example projects run through a `JacquardExamples` command in
`dotnet/examples/`, and CI runs them and the SDK's live tests against the
pinned Core.

## 0.2.0 — 2026-10-25

Expand Down
44 changes: 34 additions & 10 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,30 +146,54 @@ fails if they differ).

## .NET

The independent .NET adapter lives in `dotnet/` as package `JacquardLang.Host`
(`dotnet/src/JacquardHost`). It targets .NET 8, has no runtime dependencies
beyond the .NET base class library, and does not call Python, Node.js, Go,
Rust, or Java; it calls the C library's `poll(2)` and runs workers on Linux
and macOS only. NUnit, its adapter, and the test SDK are its only (test)
dependencies. The .NET 8 or 10 SDK is required.
The independent .NET adapter and SDK live in `dotnet/` as package
`JacquardLang.Host` (`dotnet/src/JacquardHost`, the SDK in its `Sdk/`
directory). It targets .NET 8, has no runtime dependencies beyond the .NET base
class library, and does not call Python, Node.js, Go, Rust, or Java; it calls
the C library (`poll(2)`, and for the SDK `kill(2)`, `geteuid(2)`, `mkdir(2)`,
`statx(2)` or `stat(2)`, `getpwuid_r(3)`, `getgrgid_r(3)`, and `lgetxattr(2)`; it also runs `getent passwd`) and runs on Linux and macOS only. On Linux the
SDK runs Core's commands through util-linux `setsid` when one is installed.
NUnit, its adapter, and the test SDK are its only (test) dependencies. The
.NET 8 or 10 SDK is required.

```sh
cd dotnet
dotnet test test/JacquardHost.Tests -f net8.0
dotnet test test/JacquardHost.Tests -f net10.0 # with a .NET 10 SDK
# With the pinned Core configured (eval "$(python3 ../scripts/core_pin.py install)"):
JACQUARD_REQUIRE_LIVE=1 dotnet test test/JacquardHost.Tests -f net8.0
dotnet build examples/JacquardExamples
dotnet examples/JacquardExamples/bin/Debug/net8.0/JacquardExamples.dll quickstart
```

CI runs the offline tests on .NET 8 and 10, and the live tests on both in the
shared pinned-Core job. The build treats warnings as errors, with nullable
The example command's program cache is under `dotnet/.scratch/`, and the SDK
refuses it when another user could write a directory above it: one writable
by others, or by its group unless that group is your private group (named
after you, listing no other member, and no other account in the password
database having it as its primary group; a clone made with a `umask` of 002
under user-private groups passes). The refusal names every such directory;
run `chmod go-w` on each, or pass `--workdir` under directories only you or
root may write.

The SDK tests make their scratch directories under `TMPDIR` when neither a
group nor another user may write it or any directory above it (unless
sticky), and otherwise under `/tmp` when that holds, so the refusals they
expect name only the levels they make; when neither does, they fail asking
for such a `TMPDIR`.

CI runs the offline tests on .NET 8 and 10, and the live tests on both and the
four example projects in the shared pinned-Core job. The build treats warnings as errors, with nullable
reference types and the `8.0-recommended` analyzers.
`dotnet/src/JacquardHost/Compat.cs` is generated and
`dotnet/src/JacquardHost/kit/` is a byte copy of the pinned kit.
`scripts/core_pin.py` maintains both; do not edit them by hand. The
`JacquardHost.StandIn` program under `dotnet/test/` is a fixture for the
offline tests. Copy the repository `NOTICE` to `dotnet/NOTICE` when it changes
(a test fails if they differ).
offline tests. The `JacquardExamples` command under `dotnet/examples/` reads the
rules and inputs from `examples/` directly, so there is no copy to keep in
sync. The README's C# examples are compiled test sources under
`dotnet/test/JacquardHost.Tests/Readme/` (a test fails if they differ). Copy
the repository `NOTICE` to `dotnet/NOTICE` when it changes (a test fails if
they differ).

## Releasing

Expand Down
2 changes: 2 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,5 @@ prune dotnet/test/JacquardHost.Tests/bin
prune dotnet/test/JacquardHost.Tests/obj
prune dotnet/test/JacquardHost.StandIn/bin
prune dotnet/test/JacquardHost.StandIn/obj
prune dotnet/examples/JacquardExamples/bin
prune dotnet/examples/JacquardExamples/obj
22 changes: 12 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
# Jacquard Host

Call checked Jacquard functions from Python, TypeScript, Go, Rust, or Java and implement
Call checked Jacquard functions from Python, TypeScript, Go, Rust, Java, or .NET and implement
their declared host operations with ordinary services. Jacquard owns the decision
logic; the host owns persistence, I/O, and application lifetimes.

The SDK prepares and caches checked programs, converts native Python values,
binds services to exact operation identities, and retains the raw evidence for
every invocation. Python 3.11+, Node.js 22+, Go 1.24+, Rust 1.88+, or Java 21+,
with no third-party runtime dependencies. The [TypeScript SDK](typescript/README.md),
the [Go SDK](go/README.md), the [Rust SDK](rust/README.md), and the
[Java SDK](java/README.md) implement the pinned protocol independently. The
every invocation. Python 3.11+, Node.js 22+, Go 1.24+, Rust 1.88+, Java 21+, or
.NET 8+, with no third-party runtime dependencies. The
[TypeScript SDK](typescript/README.md), the [Go SDK](go/README.md), the
[Rust SDK](rust/README.md), the [Java SDK](java/README.md), and the
[.NET SDK](dotnet/README.md) implement the pinned protocol independently. The
TypeScript SDK supports async service methods, the Go SDK maps native Go types
and context cancellation, the Rust SDK maps native Rust types through `record!`
and `variants!` declarations, and the Java SDK maps records, enums, and sealed
interfaces through explicit codecs. A protocol-level [.NET](dotnet/README.md)
(.NET 8+) adapter also implements the pinned protocol independently. It has a
closed operation registry and kit replay, but no SDK layer yet.
and `variants!` declarations, the Java SDK maps records, enums, and sealed
interfaces through explicit codecs, and the .NET SDK maps records, enumerations,
and type hierarchies through explicit codecs without reflection over members,
with `CancellationToken` cancellation.

**Experimental:** the SDK targets Core's pinned `jacquard-host-v0` protocol.
Core is installed separately. Each invocation runs one fresh, serial worker;
Expand All @@ -25,7 +26,8 @@ the host and operating system are trusted. See [limits](docs/LIMITS.md).

For Node.js, follow the [TypeScript walkthrough](typescript/README.md); for Go,
the [Go walkthrough](go/README.md); for Rust, the [Rust walkthrough](rust/README.md);
for Java, the [Java walkthrough](java/README.md). For Python:
for Java, the [Java walkthrough](java/README.md); for .NET, the
[.NET walkthrough](dotnet/README.md). For Python:

From a clone of this repository:

Expand Down
Loading
Loading