From 250d0511c01d7c5dde1fc1a41a2a42f7b4514125 Mon Sep 17 00:00:00 2001 From: giles Date: Mon, 18 May 2026 22:00:17 +0000 Subject: [PATCH] erlang: wire crypto:hash/2 against crypto-sha256/512/sha3-256 (Phase 8, +6 ffi tests) --- lib/erlang/runtime.sx | 93 +++++++++++++++++++++++++++++++++++++++++ lib/erlang/tests/ffi.sx | 40 +++++++++++++----- plans/erlang-on-sx.md | 2 +- 3 files changed, 124 insertions(+), 11 deletions(-) diff --git a/lib/erlang/runtime.sx b/lib/erlang/runtime.sx index a7d7bc2a..5ef1a109 100644 --- a/lib/erlang/runtime.sx +++ b/lib/erlang/runtime.sx @@ -1372,6 +1372,97 @@ (er-classify-file-error (nth err 0)))) :else (er-mk-atom "ok"))))))) + +;; ── crypto / cid / file:list_dir (Phase 8 FFI — host primitives) ── +;; Wired against loops/fed-prims host primitives (see plans Blockers +;; "RESOLVED 2026-05-18"). Term marshalling at the boundary: +;; Erlang binary/string/charlist -> SX byte-string via er-source-to-string; +;; results -> Erlang binary via er-mk-binary. + +(define er-hexval + (fn (c) + (let ((v (char->integer c))) + (cond + (and (>= v 48) (<= v 57)) (- v 48) ;; 0-9 + (and (>= v 97) (<= v 102)) (+ 10 (- v 97)) ;; a-f + (and (>= v 65) (<= v 70)) (+ 10 (- v 65)) ;; A-F + :else 0)))) + +(define er-hex->bytes + (fn (hex) + (let ((cs (string->list hex)) (out (list)) (n (string-length hex))) + (for-each + (fn (i) + (append! out + (+ (* 16 (er-hexval (nth cs (* i 2)))) + (er-hexval (nth cs (+ (* i 2) 1)))))) + (range 0 (truncate (/ n 2)))) + out))) + +;; crypto:hash(Type, Data) -> raw digest binary. Type is an Erlang +;; atom (sha256 | sha512 | sha3_256). Bad type / non-binary -> badarg. +(define er-bif-crypto-hash + (fn (vs) + (let ((ty (nth vs 0)) (data (er-source-to-string (nth vs 1)))) + (cond + (or (not (er-atom? ty)) (= data nil)) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + :else + (let ((name (get ty :name))) + (let ((hex (cond + (= name "sha256") (crypto-sha256 data) + (= name "sha512") (crypto-sha512 data) + (= name "sha3_256") (crypto-sha3-256 data) + :else nil))) + (cond + (= hex nil) (raise (er-mk-error-marker (er-mk-atom "badarg"))) + :else (er-mk-binary (er-hex->bytes hex))))))))) + +;; cid:from_bytes(Bin) -> CIDv1 (raw codec 0x55, sha2-256 multihash) +;; as an Erlang binary string. +(define er-bif-cid-from-bytes + (fn (vs) + (let ((data (er-source-to-string (nth vs 0)))) + (cond + (= data nil) (raise (er-mk-error-marker (er-mk-atom "badarg"))) + :else + (let ((digest (er-hex->bytes (crypto-sha256 data)))) + (let ((mh (list->string + (map integer->char (append (list 18 32) digest))))) + (er-mk-binary + (map char->integer + (string->list (cid-from-bytes 85 mh)))))))))) + +;; cid:to_string(Term) -> canonical CIDv1 (dag-cbor) of the term, +;; as an Erlang binary string. +(define er-bif-cid-to-string + (fn (vs) + ;; Canonical CID of the term's stable string form. (cbor-encode + ;; rejects symbols, so er-to-sx of compound terms is unencodable; + ;; er-format-value yields a canonical SX string per term value.) + (er-mk-binary + (map char->integer + (string->list (cid-from-sx (er-format-value (nth vs 0)))))))) + +;; file:list_dir(Path) -> {ok, [Binary]} | {error, Reason} +(define er-bif-file-list-dir + (fn (vs) + (let ((path (er-source-to-string (nth vs 0)))) + (cond + (= path nil) + (er-mk-tuple (list (er-mk-atom "error") (er-mk-atom "badarg"))) + :else + (let ((res (list nil)) (err (list nil))) + (guard (c (:else (set-nth! err 0 c))) + (set-nth! res 0 (file-list-dir path))) + (cond + (not (= (nth err 0) nil)) + (er-mk-tuple (list (er-mk-atom "error") + (er-classify-file-error (nth err 0)))) + :else + (er-mk-tuple (list (er-mk-atom "ok") + (er-of-sx (nth res 0)))))))))) + ;; ── builtin BIF registrations (Phase 8 migration) ──────────────── ;; Populates `er-bif-registry` with every existing built-in BIF. Each ;; entry is keyed by "Module/Name/Arity"; multi-arity BIFs register @@ -1466,6 +1557,8 @@ (er-register-bif! "file" "read_file" 1 er-bif-file-read-file) (er-register-bif! "file" "write_file" 2 er-bif-file-write-file) (er-register-bif! "file" "delete" 1 er-bif-file-delete) + ;; Phase 8 FFI — host-primitive BIFs (loops/fed-prims) + (er-register-pure-bif! "crypto" "hash" 2 er-bif-crypto-hash) (er-mk-atom "ok"))) ;; Register everything at load time. diff --git a/lib/erlang/tests/ffi.sx b/lib/erlang/tests/ffi.sx index 8c0ffaa2..b3dbdf94 100644 --- a/lib/erlang/tests/ffi.sx +++ b/lib/erlang/tests/ffi.sx @@ -75,18 +75,35 @@ "file:write_file(\"/tmp/er-ffi-del2.txt\", \"x\"), file:delete(\"/tmp/er-ffi-del2.txt\"), element(2, file:read_file(\"/tmp/er-ffi-del2.txt\"))")) "enoent") -;; ── Blocked BIFs (placeholder asserts so the suite documents intent) ── -;; crypto:hash/2, cid:from_bytes/1, cid:to_string/1, file:list_dir/1, -;; httpc:request/4, sqlite:* — documented in plans/erlang-on-sx.md -;; under Blockers. When the host runtime gains the underlying primitive, -;; the wrappers land in runtime.sx and tests appear here. For now we -;; assert each is NOT registered, so a future iteration that adds them -;; without updating this file fails fast. +(er-ffi-test + "crypto:hash sha256 -> 32-byte binary" + (ffi-ev "byte_size(crypto:hash(sha256, <<97,98,99>>))") + 32) (er-ffi-test - "crypto:hash unregistered" - (er-lookup-bif "crypto" "hash" 2) - nil) + "crypto:hash sha512 -> 64-byte binary" + (ffi-ev "byte_size(crypto:hash(sha512, <<97,98,99>>))") + 64) + +(er-ffi-test + "crypto:hash sha3_256 is_binary" + (ffi-nm (ffi-ev "is_binary(crypto:hash(sha3_256, <<120>>))")) + "true") + +(er-ffi-test + "crypto:hash deterministic" + (ffi-nm (ffi-ev "crypto:hash(sha256, <<97>>) =:= crypto:hash(sha256, <<97>>)")) + "true") + +(er-ffi-test + "crypto:hash distinct inputs distinct digests" + (ffi-nm (ffi-ev "crypto:hash(sha256, <<97>>) =/= crypto:hash(sha256, <<98>>)")) + "true") + +(er-ffi-test + "crypto:hash bad type -> error:badarg" + (ffi-nm (ffi-ev "try crypto:hash(md5, <<120>>) catch error:badarg -> ok end")) + "ok") (er-ffi-test "cid:from_bytes unregistered" @@ -98,6 +115,9 @@ (er-lookup-bif "file" "list_dir" 1) nil) +;; ── Still deferred (no host primitive): httpc (HTTP client, v2), +;; sqlite-* (v2 indexes). Assert NOT registered so a future iteration +;; that wires them without updating this suite fails fast. (er-ffi-test "httpc:request unregistered" (er-lookup-bif "httpc" "request" 4) diff --git a/plans/erlang-on-sx.md b/plans/erlang-on-sx.md index 72d7907a..138832ee 100644 --- a/plans/erlang-on-sx.md +++ b/plans/erlang-on-sx.md @@ -115,7 +115,7 @@ Replace today's hardcoded BIF dispatch (`er-apply-bif`/`er-apply-remote-bif` in - [x] BIF registry: `er-bif-registry` global dict keyed by `"Module/Name/Arity"`, with `er-register-bif!`/`er-register-pure-bif!`/`er-lookup-bif`/`er-list-bifs`/`er-bif-registry-reset!` helpers — **+18 runtime tests** (600/600 total). Entries are `{:module :name :arity :fn :pure?}`. Arity is part of the key so `m:f/1` and `m:f/2` are independent. Re-registering the same key replaces the previous entry; reset clears. - [x] Migrate existing local + remote BIFs (length/hd/tl/lists:*/io:format/ets:*/etc.) onto the registry; delete the giant `cond` dispatch in `er-apply-bif`/`er-apply-remote-bif`. Conformance held at **600/600** after migration (baseline was 600, not the plan-text's 530 — the text was authored before Phase 7 work added rows). 67 builtin registrations across `erlang`/`lists`/`io`/`ets`/`code` modules; multi-arity BIFs (`is_function`, `spawn`, `exit`, `io:format`, `lists:seq`, `ets:delete`) register once per arity, all pointing at the same impl which dispatches on `(len vs)` internally. The four per-module cond dispatchers (`er-apply-lists-bif`, `er-apply-io-bif`, `er-apply-ets-bif`, `er-apply-code-bif`) are deleted. `er-apply-bif` and `er-apply-remote-bif` are now ~5-line registry lookups; user modules still win precedence over the registry. - [x] Term-marshalling helpers: `er-of-sx` (SX → Erlang) and `er-to-sx` (Erlang → SX). atom ↔ symbol, nil ↔ `()`, cons → list, tuple → list (one-way; tuples flatten), binary ↔ SX string, integer / float / boolean passthrough. **+23 runtime tests** (623/623 total). Erlang maps (`dict ↔ map`) deferred — Erlang map term not implemented in this port; will land when `#{}` syntax does. Pids, refs, funs pass through unchanged. SX strings on the way back become Erlang binaries (most useful FFI return shape). -- [ ] `crypto:hash/2` — **BLOCKED** (no `sha256`/`sha512`/`blake3` primitive in this SX runtime). See Blockers. +- [x] `crypto:hash/2` — **WIRED 2026-05-18** against `crypto-sha256`/`crypto-sha512`/`crypto-sha3-256` (loops/fed-prims). `crypto:hash(Type, Data)`: `Type` ∈ `sha256|sha512|sha3_256` atom; `Data` an Erlang binary/string/charlist (→ SX byte-string via `er-source-to-string`). Returns the **raw digest as an Erlang binary** (host hex → bytes via `er-hex->bytes`). Bad type / non-binary → `error:badarg`. 6 ffi tests (digest sizes 32/64, sha3 is_binary, deterministic, distinct, badarg). - [ ] `cid:from_bytes/1`, `cid:to_string/1` — **BLOCKED** (needs `crypto:hash/2`). See Blockers. - [x] `file:read_file/1`, `file:write_file/2`, `file:delete/1` — **+10 eval tests** (633/633 total). Returns `{ok, Binary}` / `ok` / `{error, Reason}` where Reason is `enoent`/`eacces`/`enotdir`/`eisdir`/`posix_error` (classified from the SX `file-read`/`-write`/`-delete` exception string). Path accepts SX string, Erlang binary, or Erlang char-code list. `file:list_dir/1` deferred — no directory-listing primitive in this SX runtime; see Blockers. - [ ] `httpc:request/4` — **BLOCKED** (no HTTP client primitive). See Blockers.