v0.9.5
[0.9.5] — 2026-06-04
Added
-
Playground shows its deployed version, and the API auto-deploys on
release tags. The playground header now carries a version pill — a
__NURL_VERSION__placeholder inindex.htmlis stamped at image-build
time from theNURL_VERSIONbuild-arg (devfor local builds). A new
.github/workflows/api-deploy.ymlbuilds the API image on av*tag (or
manual dispatch), pushes it to Docker Hub under the exact semver
(nurllang/nurl:vX.Y.Z— no:latest), pinscloudflare/Dockerfile's
FROMto that tag and runswrangler deploy, so a git tag is now a
reproducible playground release. The Docker image was renamed
hindurable/nurl→nurllang/nurl;registry-deploy.ymlis now
manual-only (the registry changes rarely). -
MQTT-over-WebSocket transport —
mqtt_connect_ws. Adds a WebSocket
transport alongside the raw TCP/TLS path so a client can reach a broker's
MQTT-over-WS endpoint (e.g.wss://host:8084/mqtt) — handy when a firewall
only permits the WS port inbound.wss://enables TLS with certificate
verification and negotiates themqttsubprotocol automatically; the codec
and framed packet reader stay transport-blind behind two chokepoints. New
entrypointsmqtt_connect_ws/mqtt_connect_ws_cfg;mqtt_disconnect
also sends a WS Close frame, andmqtt_reconnectrejects WS clients (no URL
to redo the upgrade).stdlib/ext/mqtt.nu. -
Package manager → MLP: login/search/info, yank, token-revoke, catalog UI
(ROADMAP §4). Rounds the registry out into a minimum lovable product.- CLI ergonomics.
nurlpkg loginstores a per-registry publish token
in~/.nurl/credentials(chmod 600) —publish/yankresolve the token
$NURL_TOKEN→ credentials, so it no longer has to live in the
environment.nurlpkg logout [--revoke]forgets it (and optionally
revokes it server-side).nurlpkg search <q>andnurlpkg info <name>
query the registry (infowith no arg still prints the local manifest).
Newstdlib/ext/credentials.nu. - Registry hygiene.
nurlpkg yank|unyank <name> <version>flips a
version's yanked flag (owner-only, viaPOST /api/v1/{yank,unyank}); the
resolver already skips yanked versions, so a yanked release disappears
from resolution.nurlpkg logout --revoke(POST /api/v1/revoke) deletes
the presented token from D1. - Catalog UI. The Worker's
/is now a searchable package list and
/packages/<name>a detail page (versions with yank state, latest
dependencies, an install snippet);GET /api/v1/search?q=backs the CLI. - Client helpers:
pkg_search(pkg_fetch.nu),pkg_yank/pkg_revoke
(pkg_publish.nu). Regressioncompiler/tests/credentials_basic.nu
(set/get/upsert/multi-registry/remove; gatedNURL_CREDS_TESTS=1, clean
under ASan/UBSan). Whole feature set verified end-to-end against the
Worker underwrangler dev: login → creds-based publish → search → info
→ yank (install then fails ResolveNoMatch) → unyank → catalog → logout
--revoke → publish rejected (PubAuth).
- CLI ergonomics.
-
Transitive registry dependencies —
nurlpkg publishsendsX-Nurl-Deps.
Publishing now includes the manifest's registry dependencies (a JSON
[{name, req}]built by__deps_json) as theX-Nurl-Depsheader, which
the registry records in the package index.pkg_publishgained a
deps_jsonparameter. With the deps in the index,resolve_registry
pulls sub-dependencies transitively — previously the index always
recordeddeps: [], so only leaf registry packages installed correctly.
Verified end-to-end against the local Cloudflare Worker: publishtdep-b,
publishtdep-a(depends ontdep-b ^1.0), theninstalla consumer of
onlytdep-a→ both land indeps/and the lock. Registry now supports
real dependency graphs.stdlib/ext/pkg_publish.nu,tools/nurlpkg/main.nu. -
Package registry service — Cloudflare Worker + R2 + D1 (
registry/,
ROADMAP §4 phase 6). The deployable server side of the ecosystem, in
TypeScript. The read path serves the staticindex/<name>.json+
content-addressedpkgs/<name>/<name>-<v>.tar.gzfrom R2 (cacheable, no
compute); the write pathPOST /api/v1/publishauthenticates a Bearer
token (peppered SHA-256 looked up in D1), enforces first-publisher name
ownership + version immutability, recomputes the tarball SHA-256
server-side (never trusts a client digest), and writes the tarball +
updated index to R2. Identity bootstraps via GitHub OAuth (/login→
/auth/callbackmints a one-time CLI token). D1 schema in
migrations/0001_init.sql(users / tokens / packages / versions).
Implements exactly the wire contract the NURL client already drives.
Validated end-to-end locally (no Cloudflare account): under
wrangler dev(miniflare R2 + D1), the realnurlpkgbinary completes a
full publish → install round-trip plus immutability (409) and bad-token
(401) rejections —registry/test-local.sh. Ships with
registry/DEPLOY.md, aregistry-deploy.ymlGitHub Actions workflow
(guarded so a placeholder token can't trigger a broken deploy), and
secrets kept out of the repo (wrangler secret putfor
GITHUB_CLIENT_SECRET/TOKEN_PEPPER; GH Actions secrets for the
Cloudflare deploy token). This completes the registry-backed package
manager:nurlpkg publish+nurlpkg installagainst a deployable
registry, all pure-NURL on the client and standing up locally today. -
Package publishing —
stdlib/ext/pkg_publish.nu+nurlpkg publish
(ROADMAP §4 phase 5). The write side.pkg_packwalks a project tree
into a.tar.gz(excludingdeps,.git/dotfiles,nurl.lock,
target,build);pkg_publishuploads it withPOST <registry>/api/v1/publish,Authorization: Bearer <token>, and
X-Nurl-Package/X-Nurl-Versionheaders (binary body via
http_request_bytes), mapping status to PubAuth (401/403) / PubConflict
(409, version immutability) / PubRejected.nurlpkg publishpacks the
current project, prints its size + SHA-256, and uploads using the token
from$NURL_TOKENand the registry from$NURL_REGISTRY→
[package].registry→ default; a missing token or any non-2xx exits
non-zero. The registry recomputes the checksum server-side — no
client-supplied digest is trusted. Regression
compiler/tests/pkg_pack_basic.nu(offline pack + gunzip + tar_parse
membership: nested source included, deps/ + dotfiles excluded), clean
under ASan/UBSan + leak-free. Verified end-to-end against a static
pythonregistry: a full publish → install round-trip (a library
packed, uploaded with a Bearer token, then resolved + installed into a
consumer'sdeps/), plus immutability (409), bad-token (401), and
no-token rejections. This is the exact contract the Cloudflare
Worker + R2 write endpoint (phase 6) will implement. -
Verified registry install —
stdlib/ext/pkg_fetch.nu(ROADMAP §4
phase 4b). The I/O side that turns a resolvedLockPkginto files on
disk against a static-HTTP registry (R2 + CDN shape).pkg_fetch_index
GETs<registry>/index/<name>.json;pkg_install_onedownloads
<registry>/pkgs/<name>/<name>-<v>.tar.gz, verifies its SHA-256
against the recorded checksum, gunzips, and path-safetar_unpacks it
into<dest>/<name>— composing the whole pure-NURL package stack (http
binary body + sha256 + gzip + tar). Capstone regression
compiler/tests/pkg_install_e2e.nustands up a loopback NURL registry
server (serves a realtar_create+gziptarball + an index carrying
its true checksum) and drives the full pipeline resolve → download →
verify → unpack end-to-end, plus a wrong-checksum rejection
(PkgChecksumMismatch);NURL_NET_TESTS=1. Clean under ASan/UBSan.nurlpkg installis now registry-aware. It resolves the manifest's
registry deps (foo = "^1.2"or{ version, registry }), downloads +
verifies + unpacks each intodeps/<name>, and writes anurl.lock
whose registry entries carrysource = "registry+<url>"+ the tarball
checksum(path deps keep their local source). The registry URL comes
from$NURL_REGISTRY→[package].registry→ a built-in default. A
failed download or checksum mismatch makesinstallexit non-zero.
Verified end-to-end against a staticpython -m http.serverregistry
serving a GNU-tar --format=ustar | gzippackage (differential interop):
the happy path installs + locks with thesha256sum-computed checksum,
and a tampered index checksum is rejected with the package left
uninstalled. -
Binary-safe HTTP response body —
http_body_bytes/http_body_len.
http_body_strreads the response body through a NUL-terminated carrier
(truncates at the first embedded NUL). The newhttp_body_bytesreturns
an owned, length-accurate( Vec u )copy, andhttp_body_lenexposes
the byte count — required for binary downloads (package tarballs, images,
compressed payloads). Completes the binary HTTP story alongside the
earlier binary-safe request body. Regression:
compiler/tests/http_response_binary.nu(loopback server replies a
5-byteA B \0 C Dbody; client confirms full length + the NUL via
http_body_bytes;NURL_NET_TESTS=1). Clean under ASan/UBSan.
stdlib/ext/http.nu. -
Registry resolution core —
stdlib/ext/registry_index.nu+
stdlib/ext/resolver.nu(ROADMAP §4 phase 4). The read side of the
package registry. A registry serves a static JSON index per package at
<registry>/index/<name>.json(versions, each with a tarball SHA-256
checksum,yankedflag, anddeps); tarballs live at the
content-addressed<registry>/pkgs/<name>/<name>-<ver>.tar.gz, so the
whole read path is a cacheable CDN with no compute.
registry_index.nuparses an index andregindex_selectpicks the
highest non-yanked version satisfying a semver requirement.
resolver.nu'sresolve_registrywalks the transitive dependency graph
(BFS) and emits aVec[LockPkg]ready forlock_serialize— the index
fetcher is injected as a closure (name → index-JSON), so resolution is
pure and offline-testable; nurlpkg will wire it to an HTTP GET. v1 policy:
one version per name (first requirement wins; a later one must share that
version or it's ResolveConflict), sub-deps from the parent's registry,
path deps left to the existing symlink installer. Regressions:
compiler/tests/registry_index_basic.nu(parse + select + yanked
exclusion + tarball URL) andcompiler/tests/resolver_basic.nu
(transitive resolve with a mock index, ResolveNoMatch, ResolveNotFound).
Both clean under ASan/UBSan and leak-free.
Changed
- Signedness is now coupled to the value's type instead of a free-floating
side-channel — the structural fix for the wholeu-vs-signed-i8bug
class. The LLVM type (i8/i16/i32) can't distinguish NURL's unsigned
u/u16/u32from the signed types, so signedness travelled in a
separate__last_unsigned__syms entry that each of ~83 value-producing
sites had to remember to update — and ~67 didn't, leaving a stale flag
that silently sign- or zero-extended the next widen (the source of a long
run of miscompiles). Now signedness lives ing_last_unsigned_pright
next tog_last_type_ptr, andnurl_set_last_typealways resets it
(signed default): every value-producing site already calls the type-setter
(IR needs the type), so a stale "unsigned" can no longer leak. The handful
of unsigned-PRODUCING sites assert it atomically with the type via
nurl_set_last_type_u/nurl_mark_unsigned, and widen/op-selection
readers consultnurl_last_unsigned. This eliminated the stale-leak
subclass structurally; the migration also surfaced and fixed a latent gap
the old leaky channel had masked by accident — bitwise&/|
(gen_bitwise_binary) never set its result's signedness, relying on the
last operand's flag happening to survive. Net: fewer, simpler, faster
(a global vs a string-keyed map) and no longer forgettable. Bootstrap
fixed point holds; full suite + ASan/UBSan green; 500 fuzzer seeds clean
across every dimension.
Fixed
-
Two more silent unsigned-widening miscompiles (same
__last_unsigned__
side-channel hazard; fixed incompiler/nurlc.nu). Regression
compiler/tests/const_ternary_signedness.nu(7 known-answer checks).- An unsigned global const load sign-extended.
# i GUover
: u GU 200gave −56.gen_const_declnow records<const>__unsigned
(whichgen_identalready turns into__last_unsigned__on load). - A
?(ternary) result didn't carry its arms' signedness.
# i ? c (# u 200) (# u 100)sign-extended the selected value.
gen_condnow snapshots each arm's__last_unsigned__and sets the
result flag (the arms share a type, so either suffices).
- An unsigned global const load sign-extended.
-
A call to an unsigned-returning function sign-extended at the call
site.# i ( f )wheref → ureturns 200 gave −56 (and likewise for
u16/u32): the call site never carried the callee's return signedness
onto the__last_unsigned__side-channel the enclosing widening cast
reads (the LLVM return type i8/i16/i32 can't distinguishufromi8).
scan_fn_sigsnow records<fn>__ret_unsignedin the persistent pre-pass
symbol table (the per-functiongen_fn_declscope doesn't reach call
sites), andgen_callre-asserts it on__last_unsigned__after the
call. Regressioncompiler/tests/fn_return_signedness.nu(5 known-answer
checks). Bootstrap fixed point holds; full suite + ASan/UBSan green. -
Narrow sized-int enum payloads now compile and round-trip correctly.
An enum variant carrying au/i8/u16/i16/u32/i32payload (e.g.
: | E { None Val u }) was accepted by the front-end but emitted invalid
IR:gen_agg_litonly converted i64/i32 payloads into the enum's pointer
slot (so an i8 payload hitinsertvalue …, i8 …against aptrfield —
clang reject), andgen_matchonly un-converted i1/i64 (storing aptr
into ani8binding). Now construction widens a narrow payload to i64
(zext for an unsigned payload, sext for signed — from the payload
signednessgen_enum_declnow records) beforeinttoptr, and the match
ptrtoints back and truncs to the payload width, carrying the payload's
signedness onto the binding so a later widen zero-extends an unsigned
payload. Found by hand-probing the fuzzer's struct dimension outward.
Bootstrap fixed point holds; full suite + ASan/UBSan green. Regression
compiler/tests/enum_payload_signedness.nu(5 known-answer checks). -
Two silent struct-field signedness miscompiles (same fuzzer, extended
with a struct dimension; same root cause — the LLVM field type can't carry
NURL's signedness). Both fixed incompiler/nurlc.nu; regression
compiler/tests/struct_field_signedness.nu(8 known-answer checks);
validated by 600 fuzzer seeds with the struct dimension.- Reading an unsigned field sign-extended.
# i . rec u8fieldover a
u/u16/u32field holding e.g. 200 read back −56:gen_membernever
surfaced the field's declared signedness onto__last_unsigned__.
gen_struct_declnow records<S>__<field>__unsigned, and both the
value (extractvalue) and pointer (GEP+load) field-load paths set the
flag from it. - Constructing a wider field from a narrower unsigned value
sign-extended.@ Wide { # u 130 }into ani64field stored −126
instead of 130 —gen_agg_lit's field-store widening hardcodedsext.
It now pickszextwhen the field value is unsigned (the
__last_unsigned__snapshot it already takes),sextotherwise.
- Reading an unsigned field sign-extended.
-
Two silent integer miscompiles, found by a new differential fuzzer
(tools/fuzz) and fixed at the root incompiler/nurlc.nu. Both
produced wrong values with no error — the worst class of bug.- Unsigned-byte cast widening sign-extended.
# i64 # u 217gave
−39 instead of 217: a nested cast-to-unsigned never set the
__last_unsigned__side-channel the enclosing widening cast consults,
so it defaulted tosext.gen_castnow records the cast target's
signedness for an enclosing widen / binop / shift. (Previously only
casts whose subject was a typed binding — wheregen_identsets the
flag — widened correctly.) - Signed
i8arithmetic treated as unsigned.gen_binaryinferred
unsignedness from the LLVM typei8, but both the unsigned NURLu
and the signedi8lower to LLVM i8 — so signed i8/ % >> <
selectedudiv/urem/lshr/icmp u*and the result was marked
unsigned, silently zero-extending a negative value at the next widen.
Signedness now comes solely from the__last_unsigned__flag (set by
gen_identfrom a binding's__unsignedand bygen_castfrom an
unsigned cast target), never from the ambiguous LLVM type.
Bootstrap fixed point holds; full suite + ASan/UBSan green. Regression
compiler/tests/cast_signedness.nu(12 known-answer checks). Validated
by 340 fuzzer seeds (0 divergences).
- Unsigned-byte cast widening sign-extended.
-
Three silent int↔float conversion miscompiles (same fuzzer, extended
with float round-trip + comparison + store-coercion probes; fixed in
gen_cast). Same root cause — the LLVM integer type can't carry
signedness, so it must ride the__last_unsigned__side-channel.- Unsigned int → float used
sitofp.# f # u32 0x80000001became a
negative float (≈ −2.1e9 instead of +2.1e9);# f # u 200became
−56. Nowuitofpwhen the source is unsigned. - Float → int ignored target signedness. Now
fptouifor an unsigned
target (a value above the signed max no longer becomes poison), else
fptosi. - A float result leaked its source int's stale unsigned flag. After
# i64 # f # u …, the still-set__last_unsigned__made a surrounding
*//pickudivon a negative product (e.g.−65 / 7computed as
an unsigned divide → garbage). Float-producing casts now clear the
flag; float→int casts set it from the target. Regression
compiler/tests/cast_int_float.nu(9 known-answer checks). Validated by
600 fuzzer seeds with the new float dimension (0 divergences).
- Unsigned int → float used
Added
-
Differential fuzzer for
nurlcinteger codegen (tools/fuzz).
gen.pygenerates random sized-integer expression trees and, from the
same tree, both a self-checking NURL program (prints each result's exact
64-bit pattern) and a Python reference oracle with explicit
two's-complement / width / signedness semantics.fuzz.shcompiles each
at-O0and-O2and requiresstdout(-O0) == stdout(-O2) == oracle,
catching miscompiles that are wrong at every optimisation level. Biased
toward the historically fragile surface (width coercions, unsigned
arithmetic, mixed signed/unsigned); generates no UB. Found and fixed two
silent miscompiles on its first run (see Fixed, above). See
tools/fuzz/README.md. Subsequently extended withlet-binding store
coercion, variable reuse, comparison operators, and int→float→int
round-trips — which surfaced three more (see Fixed). -
USTAR tar reader + writer —
stdlib/ext/tar.nu. Pure-NURL POSIX.1-1988
tar:tar_create(entries → archive bytes),tar_parse(bytes → entries,
in-memory), andtar_unpack(path-safe extract to disk). Composes with
gzip_compress/_decompressto make the.tar.gzpackage format the
registry will use. The reader treats archives as untrusted input:
tar_unpackrejects absolute paths and..components (TarUnsafePath) and
refuses symlink/hardlink/device members (TarUnsupported) so nothing can
escape the destination; every header checksum is verified (TarBadChecksum)
and an over-long declared size is TarTruncated. v1 supports the 100-byte
namefield on write (TarPathTooLong otherwise) and honours theprefix
field on read. Bidirectionally interop-tested against GNU tar (NURL→tar xfandtar cf→NURL both round-trip). Regression:
compiler/tests/tar_basic.nu(round-trip incl. embedded NUL in file data,
gzip composition, checksum tamper,../rejection, unpack + binary
read-back); verified clean under ASan/UBSan. First building block of the
registry-backed package manager (ROADMAP §4). -
Semantic Versioning 2.0.0 —
stdlib/ext/semver.nu. Pure-NURL semver
parse / compare / render with full precedence ordering, including the
prerelease rules (§11: numeric < alphanumeric identifiers, fewer < more
identifiers, prerelease < release; build metadata ignored). Plus
version requirements:semver_req_parseturns a constraint (^1.2.3,
~1.2,>=1.0,<2.0.0,=1.2.3,1.*,*, or a bare1.2.3) into a
half-open range,semver_req_matchestests a version, and
semver_req_max_satisfyingpicks the highest matching version — the
resolution primitive the registry-backed package manager needs (ROADMAP
§4). Constraint dialect is Cargo-shaped: a bare1.2.3means^1.2.3,
use=1.2.3to pin. v1 matches prereleases by pure range containment (no
Cargo-style prerelease comparator special-casing yet). Regression:
compiler/tests/semver_basic.nu(round-trip, the canonical §11 precedence
chain, every constraint operator,max_satisfying, parse errors); clean
under ASan/UBSan and leak-free. -
Registry-ready manifest + typed lockfile.
stdlib/ext/manifest.nu's
Depgained aregistryfield andManifestgained a default
[package].registry, so a dependency can now be expressed as a path dep
({ path = "…" }), a bare registry dep (foo = "^1.2", default
registry), or an explicit registry dep
({ version = "1.0", registry = "…" });dep_is_path/dep_is_registry
discriminate. Newstdlib/ext/lockfile.nuis a typed view over
nurl.lock: aLockPkg { name, version, source, checksum }with
lock_serialize(deterministic, name-sorted, Cargo-shaped[[package]]
blocks;source/checksumomitted for path/local packages) and
lock_parse/lock_load(round-trips throughtoml.nu's
array-of-tables).checksumis the hex SHA-256 of the package tarball —
the integrity pin a registry install verifies. Regressions:
compiler/tests/manifest_registry.nu,compiler/tests/lockfile_basic.nu;
clean under ASan/UBSan, leak-free. ROADMAP §4 phase 3 (data model for
registry deps).nurlpkg's twoDepconstruction sites updated for the
new field. -
WebSocket client (RFC 6455 §4.1 + §5.3).
stdlib/ext/websocket.nu
gained the full client side to match the existing server.ws_connect
/ws_connect_withparse aws://…/wss://…URL, dial out (plain or
TLS-with-cert-verification via the runtime client-connect primitives),
send the HTTP Upgrade request with a fresh randomSec-WebSocket-Key,
and validate the101response'sSec-WebSocket-Accept. Outbound frames
are masked with a CSPRNG-drawn 4-byte key (ws_client_send_text/
_binary/_ping/_pong/_close,ws_client_write_frame,
ws_serialize_frame_masked); inbound server frames are read and required
to be unmasked (ws_client_read_frame/_read_message/
_serve_messages, which auto-pong masked). The frame reader/assembler is
now shared between both directions via an internal__ws_read_frame_ex/
__ws_read_message_exparameterised on direction — no duplicated framing
logic. Regression:compiler/tests/websocket_client.nu(RFC 6455 §5.7
masked-frame byte vector, URL parsing, and a liveNURL_NET_TESTS=1
client↔server echo round-trip proving interop with the server stack).
Example:examples/ws_client.nu(pairs withexamples/ws_echo.nu). -
Binary-safe HTTP request bodies —
http_*_bytesfamily. The s-body
http_request/http_post/http_putfamily recovers the body length
viastrlen, so a request body with embedded NUL bytes (binary file
uploads, MessagePack, protobuf) truncated at the first NUL. New
length-carrying variants take the body as a( Vec u )and ship it via
CURLOPT_COPYPOSTFIELDS+ an explicitPOSTFIELDSIZE, so the exact byte
count is sent:http_request_bytes/http_request_bytes_to,
http_post_bytes,http_put_bytes, and the streaming
http_stream_open_bytes_to. Thebodyargument is borrowed (the caller
still owns it). Binary fidelity requires the libcurl backend; the
WinHTTP/stub fallback round-trips through a NUL-terminatedsand
degrades to the old truncation.stdlib/ext/http.nu.
Fixed
- Reverse-proxy request body is now binary-safe + a latent use-after-free
is closed.proxy_stream_to_conn_withforwarded the upstream request
body by converting the request's( Vec u )body to a NUL-terminateds
and shipping it throughCURLOPT_POSTFIELDS(strlen-sized), truncating
binary uploads at the first NUL. Worse, the streaming opener set
non-copyingCURLOPT_POSTFIELDSand the proxy freed the body buffer
before the firstmulti_performread it — a dangling-pointer read that
only escaped notice on small JSON bodies. Both are fixed by routing the
length-tracked body throughhttp_stream_open_bytes_to, which uses
CURLOPT_COPYPOSTFIELDS(libcurl snapshots the bytes at open time, so the
caller may free immediately and embedded NULs survive). Regression:
compiler/tests/http_binary_body.nu(NURL clienthttp_post_bytesof a
5-byteA B \0 C Dbody to a loopback NURL server, asserting the server
parsed all 5 bytes;NURL_NET_TESTS=1). Verified clean under ASan/UBSan.
stdlib/ext/http.nu,stdlib/ext/http_proxy.nu.