F1.2: std.hash zero-heap [64]u8 hex API + chunked file + pinned vectors

Make the SHA-256 digest path allocation-free (foundation heap-discipline):

- final() and sha256_hex() now return the 64-char lowercase hex digest as
  a [64]u8 by value on the stack; the cstring(64) heap allocation is gone.
- sha256_file() streams the file in fixed 64KB stack chunks via open_file/
  File.read/File.close (defer-closed on every path) instead of slurping it
  with read_file; peak memory is O(chunk), not O(filesize).

Tests (compare via a zero-copy string view over the [64]u8):
- 0710 updated to the by-value API (output unchanged).
- 0711 known-answer vectors: "", "abc", NIST-56/112, padding boundaries
  {0,55,56,57,63,64,65,119,120}, and 1000 / 1,000,000 'a' repeats, each
  pinned to its published digest (cross-checked with shasum -a 256).
- 0712 streaming equivalence (one-shot == byte-at-a-time == split-mid-block
  == split-on-boundary) plus sha256_file(temp) == in-memory digest.

src/ untouched. zig build && zig build test && tests/run_examples.sh green.
This commit is contained in:
agra
2026-06-04 00:08:46 +03:00
parent ee1e097335
commit f9bc593bb8
10 changed files with 232 additions and 19 deletions

View File

@@ -7,17 +7,27 @@
// `& MASK32`, so the result is identical regardless of the host's
// native integer width or overflow behaviour.
//
// Zero-heap: the digest path never touches `context.allocator`. The
// hash is a fixed `[64]u8` of lowercase hex returned by value on the
// stack, and file hashing streams the input in fixed-size chunks, so
// peak memory is O(chunk) regardless of file size.
//
// Streaming API (the by-value `init` / `*self` pattern):
//
// h := hash.init(); // Sha256, stack-local
// h.update("hello, "); // absorb across calls
// h.update("world");
// digest := h.final(); // 64-char lowercase hex
// digest := h.final(); // [64]u8, 64-char lowercase hex, by value
//
// One-shot convenience:
//
// digest := hash.sha256_hex("abc");
// digest := hash.sha256_file("path\0"); // ?string, null on I/O error
// digest := hash.sha256_hex("abc"); // [64]u8 by value
// digest := hash.sha256_file("path\0"); // ?[64]u8, null on I/O error
//
// To print or compare a digest, build a `string` VIEW over it (no copy):
//
// d := hash.sha256_hex("abc");
// view := string.{ ptr = @d[0], len = 64 };
// =====================================================================
#import "modules/std.sx";
@@ -127,8 +137,9 @@ Sha256 :: struct {
}
// Finish: apply FIPS padding and emit the 32-byte digest as 64
// lowercase hex characters. The state is consumed by this call.
final :: (self: *Sha256) -> string {
// lowercase hex characters in a stack `[64]u8`, returned by value.
// The state is consumed by this call. No heap allocation.
final :: (self: *Sha256) -> [64]u8 {
bit_len := self.total_len * 8;
// 0x80 terminator, then zero-pad until 56 bytes mod 64.
@@ -156,7 +167,7 @@ Sha256 :: struct {
self.process_block();
self.buf_len = 0;
digest := cstring(64);
digest : [64]u8 = ---;
i := 0;
while i < 8 {
word := self.h[i] & MASK32;
@@ -196,16 +207,32 @@ init :: () -> Sha256 {
s
}
// One-shot: digest of a single buffer as 64-char lowercase hex.
sha256_hex :: (data: string) -> string {
// One-shot: digest of a single buffer as 64-char lowercase hex,
// returned by value. No heap allocation.
sha256_hex :: (data: string) -> [64]u8 {
h := init();
h.update(data);
h.final()
}
// Digest of a file's contents. Returns null if the file can't be read.
sha256_file :: (path: [:0]u8) -> ?string {
content := read_file(path);
if content == null { return null; }
sha256_hex(content!)
// Digest of a file's contents, returned by value. Streams the file in
// fixed 64KB chunks, so peak memory is O(chunk) even for multi-hundred-
// MB artifacts. Returns null if the file can't be opened. No heap
// allocation: the chunk buffer is a stack array.
sha256_file :: (path: [:0]u8) -> ?[64]u8 {
handle := open_file(path, .read);
if handle == null { return null; }
file := handle!;
defer file.close();
h := init();
chunk : [65536]u8 = ---;
reading := true;
while reading {
n := file.read(string.{ ptr = @chunk[0], len = 65536 });
if n < 0 { return null; }
if n == 0 { reading = false; }
if n > 0 { h.update(string.{ ptr = @chunk[0], len = n }); }
}
h.final()
}