tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

commit d5088b114ec5e2bbd0b5bfa7ff28332ccf16b5ab
parent 6f4e307062641c8267670495ab2db38fe9f590bb
Author: James Eversole <james@eversole.co>
Date:   Tue,  5 May 2026 19:16:16 -0500

Wire format, JS runtime, and Arborix bundle export server

Diffstat:
M.gitignore | 1+
MAGENTS.md | 233++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
MREADME.md | 6++----
Aext/bundle-runtime-profile-v1.md | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Aext/js/package.json | 17+++++++++++++++++
Aext/js/src/bundle.js | 188+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/js/src/cli.js | 249+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/js/src/codecs.js | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/js/src/manifest.js | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/js/src/merkle.js | 276+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/js/src/tree.js | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/js/test/bundle.test.js | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/js/test/merkle.test.js | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/js/test/reduce.test.js | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aext/js/test/run-bundle.test.js | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/ContentStore.hs | 77++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Msrc/FileEval.hs | 48++++++++++++++++++++++++++++++++++++++++--------
Msrc/Main.hs | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/REPL.hs | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/Server.hs | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/Wire.hs | 809+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/Spec.hs | 283+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/fixtures/equalQ.tri.bundle | 0
Atest/fixtures/false.tri.bundle | 0
Atest/fixtures/id.tri.bundle | 0
Atest/fixtures/notQ.tri | 2++
Atest/fixtures/notQ.tri.bundle | 0
Atest/fixtures/true.tri.bundle | 0
Mtricu.cabal | 14++++++++++++++
29 files changed, 3369 insertions(+), 67 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -6,6 +6,7 @@ /Dockerfile /config.dhall /result +/result* .aider* WD bin/ diff --git a/AGENTS.md b/AGENTS.md @@ -1,38 +1,22 @@ -# AGENTS.md — tricu Project Guide +# AGENTS.md - tricu Project Guide > For AI agents and contributors working in this repository. ## 1. Build & Test -**`nix build .#` always runs tests.** This is the primary and only way to build and validate. - ```bash -# Full build + tests (this is the default) +# Full build + tests nix build .# - -# Build only (skip tests) -nix build .#package - -# Build the test-specific variant with doCheck enforced -nix build .#test -nix flake check - -# Dev shell (includes ghcid, cabal-install, ghc, upx) -nix develop .# ``` ### ⚠️ Never call `cabal` directly -This project uses a Nix flake that wraps `callCabal2nix` to produce the cabal package. All compilation, linking, and test execution are driven through Nix. Running `cabal build`, `cabal test`, `cabal repl`, or `cabal install` directly will use the system GHC (or `.stack-work`) and can produce artifacts that differ from the Nix-built ones — especially regarding `megaparsec` which is a project dependency. - > **Rule of thumb:** if it builds, links, or tests, it goes through `nix`. ## 2. Project Overview **tricu** (pronounced "tree-shoe") is a programming-language experiment written in Haskell. It implements [Triage Calculus](https://olydis.medium.com/a-visual-introduction-to-tree-calculus-2f4a34ceffc2), an extension of Barry Jay's Tree Calculus, with lambda-abstraction sugar that gets eliminated back to pure tree calculus terms. -tricu is Lojban for "tree". - ### Core types (in `src/Research.hs`) | Type | Description | @@ -45,7 +29,7 @@ tricu is Lojban for "tree". ### Source modules | Module | Purpose | -|--------|---------| +|--------|---------| | `Main.hs` | CLI entry point (`cmdargs`), three modes: `repl`, `eval`, `decode` | | `Eval.hs` | Interpreter: `evalTricu`, `result`, `evalSingle` | | `Parser.hs` | Megaparsec parser → `TricuAST` | @@ -54,11 +38,12 @@ tricu is Lojban for "tree". | `REPL.hs` | Interactive Read-Eval-Print Loop (haskeline) | | `Research.hs` | Core types, `apply` reduction, booleans, marshalling (`ofString`, `ofNumber`), output formatters (`toAscii`, `toTernaryString`, `decodeResult`) | | `ContentStore.hs` | SQLite-backed term persistence | +| `Wire.hs` | Arborix portable wire format — encode/decode/import/export of Merkle-DAG bundle blobs | ### File extensions -- `.hs` — Haskell source -- `.tri` — tricu language source (used in `lib/`, `test/`, `demos/`) +- `.hs` - Haskell source +- `.tri` - tricu language source (used in `lib/`, `test/`, `demos/`) ## 3. Test Suite @@ -72,25 +57,23 @@ nix flake check # or: nix build .#test | Group | What it covers | |-------|----------------| -| `lexer` | Megaparsec lexer — identifiers, keywords, strings, escapes, invalid tokens | -| `parser` | Parser — defs, lambda, applications, lists, comments, parentheses | +| `lexer` | Megaparsec lexer - identifiers, keywords, strings, escapes, invalid tokens | +| `parser` | Parser - defs, lambda, applications, lists, comments, parentheses | | `simpleEvaluation` | Core `apply` reduction rules, variable substitution, immutability | | `lambdas` | Lambda elimination, SKI calculus, higher-order functions, currying, shadowing, free vars | -| `providedLibraries` | `lib/list.tri` — triage, booleans, list ops (`head`, `tail`, `map`, `emptyList?`, `append`, `equal?`) | +| `providedLibraries` | `lib/list.tri` - triage, booleans, list ops (`head`, `tail`, `map`, `emptyList?`, `append`, `equal?`) | | `fileEval` | Loading `.tri` files, multi-file context, decode | | `modules` | `!import`, cyclic deps, namespacing, multi-level imports, unresolved vars, local namespaces | -| `demos` | `demos/*.tri` — structural equality, `toSource`, `size`, level-order traversal | -| `decoding` | `decodeResult` — Leaf, numbers, strings, lists, mixed | +| `demos` | `demos/*.tri` - structural equality, `toSource`, `size`, level-order traversal | +| `decoding` | `decodeResult` - Leaf, numbers, strings, lists, mixed | | `elimLambdaSingle` | Lambda elimination: eta reduction, SDef binding, semantics preservation | | `stressElimLambda` | Lambda elimination stress test: 200 vars, 800-body curried lambda | -### Adding tests +### Suggesting tests -1. Append a `testCase "Description" $ do ...` block to the appropriate test group in `test/Spec.hs`. -2. Import any modules you need (lexer/parser are available via `runParser` from `Text.Megapparsec`; evaluation via `evalTricu`, `parseTricu`, `result`). -3. Run `nix flake check` to verify. +You do not write or modify tests. The user writes tests to constrain your outputs. You must adhere your code to tests or suggest modifications to tests. -> The test-suite in `tricu.cabal` pulls in `src/` as `hs-source-dirs`, so tests import modules directly (e.g., `import Eval`, `import Lexer`). This is intentional — tests exercise the full pipeline end-to-end. +If the user gives you explicit permission to implement a test you may proceed. ## 4. tricu Language Quick Reference @@ -106,7 +89,6 @@ head (map f xs) → From lib/list.tri !import "./path.tri" NS → Import file under namespace -- line comment -|- block comment -| ``` ## 5. Output Formats @@ -131,12 +113,121 @@ NLeaf → 0x00 NStem(h) → 0x01 || h (32 bytes) NFork(l,r) → 0x02 || l (32 bytes) || r (32 bytes) -hash = SHA256("tricu.merkle.node.v1" <> 0x00 <> serialized_node) +hash = SHA256("arborix.merkle.node.v1" <> 0x00 <> serialized_node) ``` This is stored in SQLite via `ContentStore.hs`. Hash suffixes on identifiers (e.g., `foo_abc123...`) are validated: 16–64 hex characters (SHA256). -## 7. Directory Layout +## 7. Arborix Portable Wire Format + +The **Arborix wire format** (module `Wire.hs`) defines a portable binary bundle for exchanging Tree Calculus terms, their Merkle DAGs, and associated metadata. It is versioned and schema-driven. + +### Header + +``` ++------------------+-----------------+------------------+----------------+ +| Magic (8 bytes) | Major (2 bytes) | Minor (2 bytes) | Section Count | +| | | | (4 bytes) | ++------------------+-----------------+------------------+----------------+ +| Flags (8 bytes) | Dir Offset (8 bytes) ++------------------+-----------------+------------------+ +``` + +- **Magic**: `ARBORIX\0` (`0x41 0x52 0x42 0x4f 0x52 0x49 0x58 0x00`) +- **Header length**: 32 bytes +- **Major version**: `1` | **Minor version**: `0` + +### Section Directory + +Immediately follows the header. Each section entry is 60 bytes: + +``` ++------------------+------------------+-----------------+------------------+ +| Type (4 bytes) | Version (2 bytes)| Flags (2 bytes) | Compression (2) | ++------------------+------------------+-----------------+------------------+ +| Digest Algo (2) | Offset (8 bytes) | Length (8 bytes)| SHA256 digest (32)| ++------------------+------------------+-----------------+------------------+ +``` + +Known section types: + +| Type | Name | Required | Description | +|------|-----------|----------|-------------| +| 1 | manifest | Yes | JSON manifest metadata | +| 2 | nodes | Yes | Binary Merkle node payloads | + +### Section 1 — Manifest (JSON) + +The manifest describes the bundle's semantics, exports, and schema. Key fields: + +| Field | Value | Description | +|-------|-------|-------------| +| `schema` | `"arborix.bundle.manifest.v1"` | Manifest schema version | +| `bundleType` | `"tree-calculus-executable-object"` | Bundle category | +| `tree.calculus` | `"tree-calculus.v1"` | Tree calculus version | +| `tree.nodeHash.algorithm` | `"sha256"` | Hash algorithm | +| `tree.nodeHash.domain` | `"arborix.merkle.node.v1"` | Hash domain string | +| `tree.nodePayload` | `"arborix.merkle.payload.v1"` | Payload encoding | +| `runtime.semantics` | `"tree-calculus.v1"` | Evaluation semantics | +| `runtime.abi` | `"arborix.abi.tree.v1"` | Runtime ABI | +| `closure` | `"complete"` | Bundle must be a complete DAG | +| `roots` | `[{"hash": "...", "role": "..."}]` | Named root hashes | +| `exports` | `[{"name": "...", "root": "..."}]` | Export aliases for roots | +| `metadata.createdBy` | `"arborix"` | Originator | + +### Section 2 — Nodes (Binary) + +``` ++------------------+-------------------+-------------------+-----------------+ +| Node Count (8) | Hash (32 bytes) | Payload Len (4) | Payload (N) | ++------------------+-------------------+-------------------+-----------------+ +``` + +Each node entry contains: +- 32-byte Merkle hash (hex-encoded in identifiers, raw in binary) +- 4-byte big-endian payload length +- N bytes of serialized node payload (`0x00` for Leaf, `0x01 || hash` for Stem, `0x02 || left || right` for Fork) + +### Bundle verification flow + +1. Check magic bytes +2. Validate major version +3. Parse section directory +4. For each section: verify SHA256 digest against actual bytes +5. Decode JSON manifest +6. Decode binary node entries into Merkle DAG +7. Verify all root hashes present in manifest exist in node map +8. Verify export root hashes present +9. Verify children references are complete (no dangling nodes) +10. Reject unknown critical sections + +### Data types (Wire.hs) + +| Type | Purpose | +|------|---------| +| `Bundle` | Top-level bundle: version, roots, nodes map, manifest | +| `BundleManifest` | JSON metadata: schema, tree spec, runtime spec, roots, exports | +| `TreeSpec` | Tree calculus version + hash algorithm + payload encoding | +| `NodeHashSpec` | Hash algorithm and domain string | +| `RuntimeSpec` | Semantics, evaluation order, ABI, capabilities | +| `BundleRoot` | Root hash + role (`"default"` or `"root"`) | +| `BundleExport` | Export name + root hash + kind + ABI | +| `BundleMetadata` | Optional package, version, description, license, createdBy | +| `ClosureMode` | `ClosureComplete` or `ClosurePartial` | + +### Key functions + +| Function | Signature | Purpose | +|----------|-----------|---------| +| `encodeBundle` | `Bundle → ByteString` | Serialize bundle to wire bytes | +| `decodeBundle` | `ByteString → Either String Bundle` | Parse wire bytes into Bundle | +| `verifyBundle` | `Bundle → Either String ()` | Validate DAG, manifest, roots | +| `collectReachableNodes` | `Connection → MerkleHash → IO [(MerkleHash, ByteString)]` | Traverse DAG from root | +| `exportBundle` | `Connection → [MerkleHash] → IO ByteString` | Build bundle from content store | +| `exportNamedBundle` | `Connection → [(Text, MerkleHash)] → IO ByteString` | Build with named roots | +| `importBundle` | `Connection → ByteString → IO [MerkleHash]` | Import bundle into content store | + +## 8. Directory Layout ``` tricu/ @@ -150,7 +241,8 @@ tricu/ │ ├── FileEval.hs │ ├── REPL.hs │ ├── Research.hs -│ └── ContentStore.hs +│ ├── ContentStore.hs +│ └── Wire.hs # Arborix portable wire format ├── test/ │ ├── Spec.hs # Tasty + HUnit tests │ ├── *.tri # tricu test programs @@ -168,10 +260,75 @@ tricu/ └── AGENTS.md # This file ``` -## 8. Development Tips +## 9. JS Arborix Runtime -- **Quick iteration:** `nix develop` then `ghcid` (provided in the devShell) watches files and re-runs. -- **REPL:** `nix run .#` starts the interactive REPL. +A JavaScript implementation of the Arborix portable bundle runtime lives in `ext/js/`. +It is a reference implementation — not a tricu source parser. It reads `.tri.bundle` files produced by the Haskell toolchain, verifies Merkle node hashes, reconstructs tree values, and reduces them. + +From project root: +```bash +node ext/js/src/cli.js inspect test/fixtures/id.tri.bundle +node ext/js/src/cli.js run test/fixtures/true.tri.bundle +``` + +The JS runtime implements: +- Bundle binary format parsing (header, section directory, manifest, nodes) +- SHA-256 Merkle node hash verification against canonical payloads +- Closure verification (all child references present) +- Tree reconstruction from node DAG +- Core `apply` reduction rules +- Basic codecs (decodeResult) +- CLI: `inspect` and `run` commands + +## 10. Content Store Workflow (Custom DB) + +The content store location is controlled by the `TRICU_DB_PATH` environment variable. When set, `eval` mode automatically loads all stored terms into the initial environment, so you can call any previously imported/evaluated term by name. + +```bash +# Use a local DB +export TRICU_DB_PATH=/tmp/tricu-local.db + +# Import terms from the standard library +./result/bin/tricu import -f lib/list.tri + +# Now use them in eval mode +echo "not? (t t)" | ./result/bin/tricu eval -t decode +# Output: t + +echo "not? (t t t)" | ./result/bin/tricu eval -t decode +# Output: Stem Leaf + +echo "equal? (t t) (t t t)" | ./result/bin/tricu eval -t decode +# Output: t + +# Check what's in the store +./result/bin/tricu +t> !definitions +``` + +Without `TRICU_DB_PATH` set, `eval` uses only the terms defined in the input file(s). + +## 11. Development Tips + +- **REPL:** `nix run .#` starts the interactive tricu REPL. - **Evaluate files:** `nix run .# -- eval -f demos/equality.tri` - **GHC options:** `-threaded -rtsopts -with-rtsopts=-N` for parallel runtime. Use `-N` RTS flag for multi-core. - **Upx** is in the devShell for binary compression if needed. + +## 12. Viewing Haskell Dependency Docs from Nix + +When you need Haddock documentation for a Haskell dependency available in Nixpkgs, build the package's `doc` output directly with `^doc`. + +Example: + +Replace `megaparsec` with the dependency name you need: + +```sh +nix build "nixpkgs#haskellPackages.${pkg}^doc" +``` + +View the available documentation files: + +```sh +find ./result-doc -type f \( -name '*.html' -o -name '*.haddock' \) | sort +``` diff --git a/README.md b/README.md @@ -6,14 +6,12 @@ tricu (pronounced "tree-shoe") is a programming language experiment in Haskell. tricu is the word for "tree" in Lojban: `(x1) is a tree of species/cultivar (x2)`. +I have fully embraced the slopmachine (LLM-assisted development) for this project. Nothing is stable or sacred. We will discover sanity at the end of the journey but we won't strive for it until then. + ## Acknowledgements Tree Calculus was discovered by [Barry Jay](https://github.com/barry-jay-personal/blog). The addition of Triage rules were suggested by [Johannes Bader](https://johannes-bader.com/). Johannes is also the creator of [treecalcul.us](https://treecalcul.us) which has a great intuitive code playground using his language LambAda. -## Versioning - -This really is a repo for experimentation so I'm not doing anything sane with the versioning for now. If I decide to stabilize the project we'll start anew at 2.0. - ## REPL examples ``` diff --git a/ext/bundle-runtime-profile-v1.md b/ext/bundle-runtime-profile-v1.md @@ -0,0 +1,49 @@ +1. Scope + This profile defines the minimum required behavior for runtimes that execute tricu bundles. + +2. Non-goals + No tricu source parsing. + No lambda elimination. + No module system. + No package manager. + No local DB requirement. + No authoring names beyond bundle exports. + +3. Required bundle sections + Header + Manifest/exports + Merkle nodes + +4. Optional/skippable sections + Source, debug, package metadata, signatures, provenance, etc. + +5. Entrypoint selection + Explicit export name first. + Else export named main. + Else single default root. + Else error. + +6. Node payload format + Leaf/Stem/Fork byte layouts. + +7. Hash verification + Domain string and payload hashing rules. + +8. Closure verification + All referenced child hashes must exist. + +9. Runtime representation + Suggested JS representation, but not normative. + +10. Reduction semantics + The six Tree Calculus apply rules. + +11. Codecs for v1 + Raw tree required. + Maybe string/bool optional or experimental. + +12. Required error cases + Bad magic/version, missing export, hash mismatch, malformed payload, missing child. + +13. Test fixtures + List of bundles the implementation must pass. diff --git a/ext/js/package.json b/ext/js/package.json @@ -0,0 +1,17 @@ +{ + "name": "arborix-runtime", + "version": "0.1.0", + "description": "Arborix portable bundle runtime — JavaScript reference implementation", + "type": "module", + "main": "src/bundle.js", + "bin": { + "arborix-run": "src/cli.js" + }, + "scripts": { + "test": "node --test test/*.test.js", + "inspect": "node src/cli.js inspect", + "run": "node src/cli.js run" + }, + "keywords": ["arborix", "tree-calculus", "trie", "runtime"], + "license": "MIT" +} diff --git a/ext/js/src/bundle.js b/ext/js/src/bundle.js @@ -0,0 +1,188 @@ +/** + * bundle.js — Parse an Arborix portable bundle binary into a JavaScript object. + * + * Format (v1): + * Header (32 bytes): + * Magic 8B "ARBORIX\0" + * Major 2B u16 BE (must be 1) + * Minor 2B u16 BE + * SectionCount 4B u32 BE + * Flags 8B u64 BE + * DirOffset 8B u64 BE + * Section Directory (SectionCount × 60 bytes): + * Type 4B u32 BE + * Version 2B u16 BE + * Flags 2B u16 BE (bit 0 = critical) + * Compression 2B u16 BE + * DigestAlgo 2B u16 BE + * Offset 8B u64 BE + * Length 8B u64 BE + * SHA256Digest 32B raw + */ + +import { createHash } from "node:crypto"; + +// ── Constants ─────────────────────────────────────────────────────────────── + +const MAGIC = Buffer.from([0x41, 0x52, 0x42, 0x4f, 0x52, 0x49, 0x58, 0x00]); // "ARBORIX\0" +const HEADER_LENGTH = 32; +const SECTION_ENTRY_LENGTH = 60; +const SECTION_MANIFEST = 1; +const SECTION_NODES = 2; +const FLAG_CRITICAL = 0x0001; +const COMPRESSION_NONE = 0; +const DIGEST_SHA256 = 1; +const MAJOR_VERSION = 1; +const MINOR_VERSION = 0; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function readU16BE(buf, offset) { + return buf.readUint16BE(offset); +} +function readU32BE(buf, offset) { + return buf.readUint32BE(offset); +} +function readU64BE(buf, offset) { + return buf.readBigUInt64BE(offset); +} + +function sha256(data) { + return createHash("sha256").update(data).digest(); +} + +// ── Public API ────────────────────────────────────────────────────────────── + +/** + * Parse a bundle Buffer into a Bundle object. + * + * Returns { version, sectionCount, sections } where sections maps + * section type numbers to parsed section info (offset, length, data). + */ +export function parseBundle(buffer) { + if (buffer.length < HEADER_LENGTH) { + throw new Error("bundle too short for header"); + } + + // Check magic + if (!buffer.slice(0, 8).equals(MAGIC)) { + throw new Error("invalid magic: expected ARBORIX\\0"); + } + + // Parse header + const major = readU16BE(buffer, 8); + const minor = readU16BE(buffer, 10); + const sectionCount = readU32BE(buffer, 12); + + if (major !== MAJOR_VERSION) { + throw new Error( + `unsupported bundle major version: ${major} (expected ${MAJOR_VERSION})` + ); + } + + const dirOffset = Number(readU64BE(buffer, 24)); + + // Parse section directory + const dirStart = dirOffset; + const dirEnd = dirStart + sectionCount * SECTION_ENTRY_LENGTH; + + if (buffer.length < dirEnd) { + throw new Error("bundle truncated in section directory"); + } + + const entries = []; + for (let i = 0; i < sectionCount; i++) { + const off = dirStart + i * SECTION_ENTRY_LENGTH; + const entry = { + type: readU32BE(buffer, off), + version: readU16BE(buffer, off + 4), + flags: readU16BE(buffer, off + 6), + compression: readU16BE(buffer, off + 8), + digestAlgorithm: readU16BE(buffer, off + 10), + offset: Number(readU64BE(buffer, off + 12)), + length: Number(readU64BE(buffer, off + 20)), + digest: buffer.slice(off + 28, off + 28 + 32), + }; + entries.push(entry); + } + + // Validate sections + for (const entry of entries) { + const isCritical = (entry.flags & FLAG_CRITICAL) !== 0; + const isKnown = + entry.type === SECTION_MANIFEST || entry.type === SECTION_NODES; + if (isCritical && !isKnown) { + throw new Error(`unknown critical section type: ${entry.type}`); + } + if (entry.compression !== COMPRESSION_NONE) { + throw new Error( + `unsupported compression codec in section ${entry.type}` + ); + } + if (entry.digestAlgorithm !== DIGEST_SHA256) { + throw new Error( + `unsupported digest algorithm in section ${entry.type}` + ); + } + } + + // Verify section digests and extract data + const sections = new Map(); + for (const entry of entries) { + if (entry.offset < 0 || entry.length < 0) { + throw new Error(`section ${entry.type} has negative offset/length`); + } + if (buffer.length < entry.offset + entry.length) { + throw new Error( + `section ${entry.type} extends beyond bundle end` + ); + } + + const data = buffer.slice(entry.offset, entry.offset + entry.length); + + // Verify digest + const computed = sha256(data); + if (!computed.equals(entry.digest)) { + throw new Error( + `section digest mismatch for section type ${entry.type}` + ); + } + + sections.set(entry.type, { + ...entry, + data, + }); + } + + // Check required sections + if (!sections.has(SECTION_MANIFEST)) { + throw new Error("missing required section: manifest"); + } + if (!sections.has(SECTION_NODES)) { + throw new Error("missing required section: nodes"); + } + + return { + version: `${major}.${minor}`, + sectionCount, + sections, + }; +} + +/** + * Convenience: parse and return just the manifest JSON. + */ +export function parseManifest(buffer) { + const bundle = parseBundle(buffer); + const manifestEntry = bundle.sections.get(SECTION_MANIFEST); + return JSON.parse(manifestEntry.data.toString("utf-8")); +} + +/** + * Convenience: parse and return the node section binary. + */ +export function parseNodeSection(buffer) { + const bundle = parseBundle(buffer); + const nodesEntry = bundle.sections.get(SECTION_NODES); + return nodesEntry.data; +} diff --git a/ext/js/src/cli.js b/ext/js/src/cli.js @@ -0,0 +1,249 @@ +#!/usr/bin/env node +/** + * cli.js — Minimal CLI for inspecting and running Arborix bundles. + * + * Usage: + * node cli.js inspect <bundle> + * node cli.js run <bundle> [exportName] [input] + */ + +import { readFileSync } from "node:fs"; +import { parseBundle, parseManifest } from "./bundle.js"; +import { parseNodeSection as parseNodeSectionMerkle } from "./merkle.js"; +import { + validateManifest, + selectExport, + printManifestInfo, +} from "./manifest.js"; +import { parseNodeSection as parseNodeSectionBundle } from "./bundle.js"; +import { + verifyNodeHashes, + verifyClosure, + verifyRootClosure, +} from "./merkle.js"; +import { isTree, apply, triage, isFork, isStem } from "./tree.js"; +import { decodeResult, formatTree } from "./codecs.js"; + +// ── Commands ──────────────────────────────────────────────────────────────── + +function cmdInspect(bundlePath) { + const buffer = readFileSync(bundlePath); + try { + const manifest = parseManifest(buffer); + validateManifest(manifest); + + const nodeSectionBytes = parseNodeSectionBundle(buffer); + const { nodeMap } = parseNodeSectionMerkle(nodeSectionBytes); + + console.log(`Bundle: ${bundlePath}`); + console.log(""); + + printManifestInfo(manifest, " "); + + console.log(` Nodes: ${nodeMap.size}`); + + // Verify hashes + const { verified: hashesOk, mismatches } = verifyNodeHashes(nodeMap); + console.log(` Hash verification: ${hashesOk ? "OK" : "FAIL"}`); + for (const m of mismatches) { + console.log(` MISMATCH ${m.type} ${m.hash.substring(0, 16)}... expected ${m.expected.substring(0, 16)}...`); + } + + // Verify closure + const { complete: closureOk, missing } = verifyClosure(nodeMap); + console.log(` Closure verification: ${closureOk ? "OK" : "FAIL"}`); + for (const m of missing) { + console.log(` MISSING ${m.parent.substring(0, 16)}... → ${m.child.substring(0, 16)}...`); + } + + // Verify root closure for each export + for (const exp of manifest.exports || []) { + const { complete, missingRoots } = verifyRootClosure( + nodeMap, + exp.root + ); + if (!complete) { + console.log( + ` Root closure for "${exp.name}": FAIL — missing: ${missingRoots + .map((r) => r.substring(0, 16) + "...") + .join(", ")}` + ); + } + } + + console.log(""); + console.log("Inspection complete."); + } catch (e) { + console.error(`Error: ${e.message}`); + process.exit(1); + } +} + +function cmdRun(bundlePath, exportName, inputArg) { + const buffer = readFileSync(bundlePath); + let result; + try { + const manifest = parseManifest(buffer); + validateManifest(manifest); + + const selectedExport = selectExport(manifest, exportName); + + const nodeSectionBytes = parseNodeSectionBundle(buffer); + const { nodeMap } = parseNodeSectionMerkle(nodeSectionBytes); + + // Verify hashes + const { verified, mismatches } = verifyNodeHashes(nodeMap); + if (!verified) { + console.error( + `Node hash mismatch:\n ${mismatches + .map((m) => ` ${m.type}: ${m.hash} (expected ${m.expected})`) + .join("\n")}` + ); + process.exit(1); + } + + // Reconstruct the tree for the selected export + const root = buildTreeFromNodeMap(nodeMap, selectedExport.root); + if (!isTree(root)) { + console.error("Reconstructed root is not a valid tree value"); + process.exit(1); + } + + // Apply input if provided + let term = root; + if (inputArg !== undefined) { + // TODO: parse input (string/number) into a tree + // For now, just run the term as-is + } + + // Reduce with fuel limit + const finalTerm = reduce(term, 1_000_000); + + // Print result as tree calculus form + console.log(formatTree(finalTerm)); + } catch (e) { + console.error(`Error: ${e.message}`); + process.exit(1); + } +} + +// ── Tree reconstruction ───────────────────────────────────────────────────── + +/** + * Reconstruct a tree from a node map. + * + * Node map: Map<hexHash, { type, childHash?, leftHash?, rightHash? }> + * + * Returns the tree representation: [] for Leaf, [child] for Stem, [right, left] for Fork. + * Uses memoization to avoid re-processing nodes. + */ +export function buildTreeFromNodeMap(nodeMap, hash, memo = new Map()) { + if (memo.has(hash)) return memo.get(hash); + + const node = nodeMap.get(hash); + if (!node) { + throw new Error(`missing node in bundle: ${hash}`); + } + + let tree; + switch (node.type) { + case "leaf": + tree = []; + break; + case "stem": + tree = [buildTreeFromNodeMap(nodeMap, node.childHash, memo)]; + break; + case "fork": + tree = [ + buildTreeFromNodeMap(nodeMap, node.rightHash, memo), + buildTreeFromNodeMap(nodeMap, node.leftHash, memo), + ]; + break; + default: + throw new Error(`unknown node type: ${node.type}`); + } + + memo.set(hash, tree); + return tree; +} + +// ── Reduction ─────────────────────────────────────────────────────────────── + +/** + * Reduce a term to normal form with a fuel limit. + * Uses the stack-based approach from the TS evaluator. + */ +export function reduce(term, fuel) { + const stack = [term]; + let remaining = fuel; + + while (stack.length >= 2 && remaining-- > 0) { + // Pop right (top), then left + const b = stack.pop(); // right + const a = stack.pop(); // left + + if (stack.length >= 2) { + // Push a back for potential further reduction + stack.push(a); + } + + const result = apply(a, b); + + if (isTree(result)) { + // If result is a value, push it. But if it's a Fork/Stem, + // we need to push its components for further reduction. + if (isFork(result)) { + // Push right first (so it's popped second), then left + stack.push(result[1]); // left + stack.push(result[0]); // right + } else if (isStem(result)) { + stack.push(result[0]); // child + } else { + stack.push(result); // Leaf + } + } else { + // Not a tree — push as-is (shouldn't happen after buildTree) + stack.push(result); + } + } + + if (remaining <= 0) { + throw new Error("reduction step limit exceeded"); + } + + if (stack.length === 1) { + return stack[0]; + } + return stack[0]; // fallback +} + +// ── Main ──────────────────────────────────────────────────────────────────── + +const args = process.argv.slice(2); +const command = args[0]; + +switch (command) { + case "inspect": { + if (args.length < 2) { + console.error("Usage: node cli.js inspect <bundle>"); + process.exit(1); + } + cmdInspect(args[1]); + break; + } + case "run": { + if (args.length < 2) { + console.error("Usage: node cli.js run <bundle> [exportName] [input]"); + process.exit(1); + } + cmdRun(args[1], args[2], args[3]); + break; + } + default: + console.log("Arborix JS Runtime"); + console.log(""); + console.log("Usage:"); + console.log(" node cli.js inspect <bundle>"); + console.log(" node cli.js run <bundle> [exportName] [input]"); + break; +} diff --git a/ext/js/src/codecs.js b/ext/js/src/codecs.js @@ -0,0 +1,135 @@ +/** + * codecs.js — Minimal codecs for decoding tree results. + * + * Implements: decodeResult (from Research.hs) + * - Leaf → "t" + * - Numbers: toNumber + * - Strings: toString + * - Lists: toList + * - Fallback: raw tree format + */ + +// ── toNumber ──────────────────────────────────────────────────────────────── + +/** + * Decode a tree as a binary number (big-endian). + * Leaf = 0, Fork(Leaf, rest) = 2*n, Fork(Stem Leaf, rest) = 2*n+1. + */ +export function toNumber(t) { + if (!Array.isArray(t)) return null; + if (t.length === 0) return 0; // Leaf = 0 + if (t.length !== 2) return null; // must be Fork + + const [right, left] = t; + // Fork structure: [right, left] + // left child determines bit: Leaf = 0, Stem(Leaf) = 1 + let bit; + if (Array.isArray(left) && left.length === 0) { + bit = 0; // Leaf + } else if (Array.isArray(left) && left.length === 1) { + const child = left[0]; + if (Array.isArray(child) && child.length === 0) { + bit = 1; // Stem(Leaf) = 1 + } else { + return null; // Stem of something other than Leaf + } + } else { + return null; + } + + const rest = toNumber(right); + if (rest === null) return null; + + return bit + 2 * rest; +} + +// ── toString ──────────────────────────────────────────────────────────────── + +/** + * Decode a tree as a list of numbers (characters). + * Fork(x, rest) = x : list. + */ +export function toList(t) { + if (!Array.isArray(t)) return null; + if (t.length === 0) return []; // Leaf = empty list + if (t.length !== 2) return null; // must be Fork + + const [right, left] = t; + const rest = toList(right); + if (rest === null) return null; + + return [left, ...rest]; +} + +/** + * Decode a tree as a string. + */ +export function toString(t) { + const list = toList(t); + if (list === null) return null; + try { + return list.map((ch) => String.fromCharCode(ch)).join(""); + } catch { + return null; + } +} + +// ── decodeResult ──────────────────────────────────────────────────────────── + +/** + * Decode a tree result using multiple strategies: + * 1. Leaf → "t" + * 2. String (if all chars are printable) + * 3. Number + * 4. List + * 5. Raw tree format + */ +export function decodeResult(t) { + if (!Array.isArray(t)) { + return String(t); + } + + // Leaf + if (t.length === 0) { + return "t"; + } + + // Try string first (list of char codes) + const list = toList(t); + if (list !== null && list.length > 0) { + const str = list.map((n) => { + if (n < 32 || n > 126) return null; + return String.fromCharCode(n); + }).join(""); + if (str) return `"${str}"`; + } + + // Try number + const num = toNumber(t); + if (num !== null) { + return String(num); + } + + // Try list (elements are trees) + if (t.length === 2) { + const elements = toList(t); + if (elements !== null) { + const decoded = elements.map((e) => decodeResult(e)); + return `[${decoded.join(", ")}]`; + } + } + + // Raw tree format + return formatTree(t); +} + +/** + * Format a tree as a parenthesized expression. + */ +export function formatTree(t) { + if (!Array.isArray(t)) return String(t); + if (t.length === 0) return "Leaf"; + if (t.length === 1) return `Stem(${formatTree(t[0])})`; + if (t.length === 2) return `Fork(${formatTree(t[1])}, ${formatTree(t[0])})`; + return `[${t.map(formatTree).join(", ")}]`; +} diff --git a/ext/js/src/manifest.js b/ext/js/src/manifest.js @@ -0,0 +1,167 @@ +/** + * manifest.js — Minimal manifest parsing and export lookup. + * + * The manifest is a JSON object with fields: + * schema, bundleType, tree, runtime, closure, roots, exports, + * imports, sections, metadata + * + * We parse only what we need for runtime entrypoint selection. + */ + +/** + * Validate the manifest against the runtime profile requirements. + * Throws on violation. + */ +export function validateManifest(manifest) { + if (manifest.schema !== "arborix.bundle.manifest.v1") { + throw new Error( + `unsupported manifest schema: ${manifest.schema}` + ); + } + if (manifest.bundleType !== "tree-calculus-executable-object") { + throw new Error( + `unsupported bundle type: ${manifest.bundleType}` + ); + } + + const tree = manifest.tree; + if (tree.calculus !== "tree-calculus.v1") { + throw new Error(`unsupported calculus: ${tree.calculus}`); + } + if (tree.nodeHash.algorithm !== "sha256") { + throw new Error( + `unsupported node hash algorithm: ${tree.nodeHash.algorithm}` + ); + } + if (tree.nodeHash.domain !== "tricu.merkle.node.v1" && tree.nodeHash.domain !== "arborix.merkle.node.v1") { + throw new Error( + `unsupported node hash domain: ${tree.nodeHash.domain}` + ); + } + if (tree.nodePayload !== "arborix.merkle.payload.v1") { + throw new Error(`unsupported node payload: ${tree.nodePayload}`); + } + + const runtime = manifest.runtime; + if (runtime.semantics !== "tree-calculus.v1") { + throw new Error(`unsupported runtime semantics: ${runtime.semantics}`); + } + if (runtime.abi !== "arborix.abi.tree.v1") { + throw new Error(`unsupported runtime ABI: ${runtime.abi}`); + } + if (runtime.capabilities && runtime.capabilities.length > 0) { + throw new Error( + `host/runtime capabilities not supported: ${runtime.capabilities.join(", ")}` + ); + } + + if (manifest.closure !== "complete") { + throw new Error("bundle v1 requires closure = complete"); + } + if (manifest.imports && manifest.imports.length > 0) { + throw new Error("bundle v1 requires an empty imports list"); + } + if (!manifest.roots || manifest.roots.length === 0) { + throw new Error("manifest has no roots"); + } + if (!manifest.exports || manifest.exports.length === 0) { + throw new Error("manifest has no exports"); + } + + for (const exp of manifest.exports) { + if (!exp.name) { + throw new Error("manifest export has empty name"); + } + if (!exp.root) { + throw new Error("manifest export has empty root"); + } + } +} + +/** + * Select an export hash given a requested name. + * + * Selection strategy: + * 1. Explicit export name + * 2. Export named "main" + * 3. Single export (auto-select) + * 4. Error if multiple exports and no "main" + */ +export function selectExport(manifest, requestedName) { + const exports = manifest.exports || []; + + // Strategy 1: explicit name + if (requestedName) { + const found = exports.find((e) => e.name === requestedName); + if (found) { + return found; + } + throw new Error( + `requested export "${requestedName}" not found. Available: ${exports.map((e) => e.name).join(", ")}` + ); + } + + // Strategy 2: prefer "main" + const mainExport = exports.find((e) => e.name === "main"); + if (mainExport) { + return mainExport; + } + + // Strategy 3: single export + if (exports.length === 1) { + return exports[0]; + } + + // Strategy 4: multiple exports, require explicit + throw new Error( + `multiple exports available but none named "main": ${exports.map((e) => e.name).join(", ")}. Specify an export name.` + ); +} + +/** + * Get all root hashes from the manifest. + */ +export function getRootHashes(manifest) { + return (manifest.roots || []).map((r) => r.hash); +} + +/** + * Get all export names. + */ +export function getExportNames(manifest) { + return (manifest.exports || []).map((e) => e.name); +} + +/** + * Print manifest summary info. + */ +export function printManifestInfo(manifest, indent = "") { + const tree = manifest.tree; + const runtime = manifest.runtime; + + console.log(`${indent}Schema: ${manifest.schema}`); + console.log(`${indent}Bundle type: ${manifest.bundleType}`); + console.log(`${indent}Closure: ${manifest.closure}`); + console.log(`${indent}Tree calculus: ${tree.calculus}`); + console.log(`${indent}Hash algo: ${tree.nodeHash.algorithm}`); + console.log(`${indent}Hash domain: ${tree.nodeHash.domain}`); + console.log(`${indent}Runtime: ${runtime.semantics}`); + console.log(`${indent}ABI: ${runtime.abi}`); + console.log(`${indent}Evaluation: ${runtime.evaluation || "N/A"}`); + console.log(""); + console.log(`${indent}Roots (${getRootHashes(manifest).length}):`); + for (const root of getRootHashes(manifest)) { + console.log(`${indent} ${root.substring(0, 16)}...`); + } + console.log(""); + console.log(`${indent}Exports (${getExportNames(manifest).length}):`); + for (const name of getExportNames(manifest)) { + console.log(`${indent} ${name}`); + } + + const meta = manifest.metadata; + if (meta && meta.createdBy) { + console.log(""); + console.log(`${indent}Created by: ${meta.createdBy}`); + } +} diff --git a/ext/js/src/merkle.js b/ext/js/src/merkle.js @@ -0,0 +1,276 @@ +/** + * merkle.js — Node payload decoding and hash verification. + * + * Node payload format: + * Leaf: 0x00 + * Stem: 0x01 || child_hash (32 bytes raw) + * Fork: 0x02 || left_hash (32 bytes raw) || right_hash (32 bytes raw) + * + * Hash computation: + * hash = SHA256( "tricu.merkle.node.v1" || 0x00 || node_payload ) + */ + +import { createHash } from "node:crypto"; + +// ── Constants ─────────────────────────────────────────────────────────────── + +const DOMAIN_TAG = "tricu.merkle.node.v1"; +const HASH_LENGTH = 32; // raw hash bytes +const HEX_LENGTH = 64; // hex-encoded hash length + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function rawToHex(buf) { + if (buf.length !== HASH_LENGTH) { + throw new Error(`raw hash must be ${HASH_LENGTH} bytes, got ${buf.length}`); + } + return buf.toString("hex"); +} + +function hexToRaw(hex) { + const buf = Buffer.from(hex, "hex"); + if (buf.length !== HASH_LENGTH) { + throw new Error(`hex hash must decode to ${HASH_LENGTH} bytes`); + } + return buf; +} + +function sha256(data) { + return createHash("sha256").update(data).digest(); +} + +function nodeHash(prefix, payload) { + return sha256(Buffer.concat([Buffer.from(prefix), Buffer.from([0x00]), payload])); +} + +// ── Node payload types ────────────────────────────────────────────────────── + +/** + * Deserialize a node payload into { type, childHash, leftHash, rightHash }. + * + * type: "leaf" | "stem" | "fork" + * childHash: hex string (for stem) + * leftHash: hex string (for fork) + * rightHash: hex string (for fork) + */ +export function deserializePayload(payload) { + if (payload.length === 0) { + throw new Error("empty payload"); + } + + const type = payload.readUInt8(0); + + switch (type) { + case 0x00: + if (payload.length !== 1) { + throw new Error( + `invalid leaf payload: expected 1 byte, got ${payload.length}` + ); + } + return { type: "leaf" }; + + case 0x01: + if (payload.length !== 1 + HASH_LENGTH) { + throw new Error( + `invalid stem payload: expected ${1 + HASH_LENGTH} bytes, got ${payload.length}` + ); + } + return { + type: "stem", + childHash: rawToHex(payload.slice(1, 1 + HASH_LENGTH)), + }; + + case 0x02: + if (payload.length !== 1 + 2 * HASH_LENGTH) { + throw new Error( + `invalid fork payload: expected ${1 + 2 * HASH_LENGTH} bytes, got ${payload.length}` + ); + } + return { + type: "fork", + leftHash: rawToHex(payload.slice(1, 1 + HASH_LENGTH)), + rightHash: rawToHex(payload.slice(1 + HASH_LENGTH, 1 + 2 * HASH_LENGTH)), + }; + + default: + throw new Error( + `invalid merkle node payload: unknown type 0x${type.toString(16)}` + ); + } +} + +/** + * Compute the canonical payload bytes for a given tree node structure. + */ +export function serializeNode(node) { + switch (node.type) { + case "leaf": + return Buffer.from([0x00]); + case "stem": + return Buffer.concat([Buffer.from([0x01]), hexToRaw(node.childHash)]); + case "fork": + return Buffer.concat([ + Buffer.from([0x02]), + hexToRaw(node.leftHash), + hexToRaw(node.rightHash), + ]); + } +} + +/** + * Compute the Merkle hash of a node from its type and parameters. + */ +export function computeNodeHash(node) { + const payload = serializeNode(node); + const hash = nodeHash(DOMAIN_TAG, payload); + return hash.toString("hex"); +} + +// ── Node section parsing ──────────────────────────────────────────────────── + +/** + * Parse the node section binary into a Map<hexHash, { type, payload, node }>. + * + * Node section format: + * nodeCount (8B u64 BE) + * entries[]: + * hash (32B raw) + * payloadLen (4B u32 BE) + * payload (payloadLen bytes) + */ +export function parseNodeSection(data) { + if (data.length < 8) { + throw new Error("node section too short for count"); + } + + const nodeCount = Number(data.readBigUInt64BE(0)); + let offset = 8; + + const nodeMap = new Map(); + const errors = []; + + for (let i = 0; i < nodeCount; i++) { + // Read hash + if (offset + HASH_LENGTH > data.length) { + errors.push(`node ${i}: not enough bytes for hash`); + break; + } + const hash = rawToHex(data.slice(offset, offset + HASH_LENGTH)); + offset += HASH_LENGTH; + + // Read payload length + if (offset + 4 > data.length) { + errors.push(`node ${i} (${hash}): not enough bytes for payload length`); + break; + } + const payloadLen = data.readUint32BE(offset); + offset += 4; + + // Read payload + if (offset + payloadLen > data.length) { + errors.push(`node ${i} (${hash}): payload extends beyond section end`); + break; + } + const payload = data.slice(offset, offset + payloadLen); + offset += payloadLen; + + // Deserialize payload + let node; + try { + node = deserializePayload(payload); + } catch (e) { + errors.push(`node ${i} (${hash}): ${e.message}`); + continue; + } + + nodeMap.set(hash, { + hash, + payload, + ...node, + }); + } + + if (errors.length > 0) { + throw new Error( + `node section parse errors:\n ${errors.join("\n ")}` + ); + } + + return { nodeMap, count: nodeCount }; +} + +// ── Verification ──────────────────────────────────────────────────────────── + +/** + * Verify all node hashes match their payloads. + * Returns { verified, mismatches } + */ +export function verifyNodeHashes(nodeMap) { + const mismatches = []; + + for (const [hash, node] of nodeMap) { + const expected = computeNodeHash(node); + if (hash !== expected) { + mismatches.push({ + hash, + expected, + type: node.type, + }); + } + } + + return { verified: mismatches.length === 0, mismatches }; +} + +/** + * Verify that all child references exist in the node map (closure). + * Returns { complete, missing } where missing is an array of { parent, child }. + */ +export function verifyClosure(nodeMap) { + const missing = []; + + for (const [hash, node] of nodeMap) { + if (node.type === "stem") { + if (!nodeMap.has(node.childHash)) { + missing.push({ parent: hash, child: node.childHash }); + } + } else if (node.type === "fork") { + if (!nodeMap.has(node.leftHash)) { + missing.push({ parent: hash, child: node.leftHash }); + } + if (!nodeMap.has(node.rightHash)) { + missing.push({ parent: hash, child: node.rightHash }); + } + } + } + + return { complete: missing.length === 0, missing }; +} + +/** + * Verify closure for a specific root hash (transitive reachability). + * Returns { complete, missingRoots }. + */ +export function verifyRootClosure(nodeMap, rootHash) { + const visited = new Set(); + const missingRoots = []; + + function visit(hash) { + if (visited.has(hash)) return; + if (!nodeMap.has(hash)) { + missingRoots.push(hash); + return; + } + visited.add(hash); + const node = nodeMap.get(hash); + if (node.type === "stem") { + visit(node.childHash); + } else if (node.type === "fork") { + visit(node.leftHash); + visit(node.rightHash); + } + } + + visit(rootHash); + return { complete: missingRoots.length === 0, missingRoots }; +} diff --git a/ext/js/src/tree.js b/ext/js/src/tree.js @@ -0,0 +1,125 @@ +/** + * tree.js — Runtime tree representation. + * + * The JS tree uses a simple array representation matching the + * TypeScript reference evaluator: + * + * Leaf = [] + * Stem = [child] (array length === 1) + * Fork = [right, left] (array length === 2) + * + * This is a "flattened stack" representation: when reduced, terms + * become arrays and the evaluator pops three elements at a time. + */ + +/** + * Check if a value is a Leaf (empty array). + */ +export function isLeaf(t) { + return Array.isArray(t) && t.length === 0; +} + +/** + * Check if a value is a Stem (single element array). + */ +export function isStem(t) { + return Array.isArray(t) && t.length === 1; +} + +/** + * Check if a value is a Fork (two element array). + */ +export function isFork(t) { + return Array.isArray(t) && t.length === 2; +} + +/** + * Check if a value is a valid tree calculus value (Leaf, Stem, or Fork). + */ +export function isTree(t) { + return isLeaf(t) || isStem(t) || isFork(t); +} + +/** + * Triage a tree: classify it as Leaf/Stem/Fork. + * The tree must be in normal form (no reducible redexes). + * + * Returns { kind: "leaf"|"stem"|"fork", ...rest } + */ +export function triage(t) { + if (!Array.isArray(t)) { + throw new Error("not a tree (not an array)"); + } + if (t.length === 0) return { kind: "leaf" }; + if (t.length === 1) return { kind: "stem", child: t[0] }; + if (t.length === 2) return { kind: "fork", right: t[0], left: t[1] }; + throw new Error(`not a value/binary tree: length ${t.length}`); +} + +/** + * Apply the Tree Calculus apply rules. + * + * apply(a, b) computes the application of term a to term b. + * + * Rules: + * apply(Fork(Leaf, a), _) = a + * apply(Fork(Stem(a), b), c) = apply(apply(a, c), apply(b, c)) + * apply(Fork(Fork, _, _), Leaf) = left of inner Fork + * apply(Fork(Fork, _, _), Stem) = right of inner Fork + * apply(Fork(Fork, _, _), Fork) = apply(apply(c, u), v) where c=Fork(u,v) + * apply(Leaf, b) = Stem(b) + * apply(Stem(a), b) = Fork(a, b) + * + * For Fork, the inner structure is [right, left], so: + * a = right, b = left + */ +export function apply(a, b) { + // apply(Fork(Leaf, a), _) = a + // Fork = [right, left] = [Leaf, a] → left child is Leaf + if (isFork(a) && isLeaf(a[1])) { + return a[0]; // return right child + } + + // apply(Fork(Stem(a), b), c) + if (isFork(a) && isStem(a[1])) { + const stemChild = a[1][0]; // left child of fork + const right = a[0]; // right child of fork + const innerA = stemChild; + const innerB = right; + const appliedA = apply(innerA, b); + const appliedB = apply(innerB, b); + return apply(appliedA, appliedB); + } + + // apply(Fork(Fork, _, _), Leaf) + if (isFork(a) && isFork(a[1]) && isLeaf(b)) { + return a[1][0]; // right child of inner fork (which is left child) + } + + // apply(Fork(Fork, _, _), Stem) + if (isFork(a) && isFork(a[1]) && isStem(b)) { + return a[1][1]; // left child of inner fork + } + + // apply(Fork(Fork, _, _), Fork) + if (isFork(a) && isFork(a[1]) && isFork(b)) { + // b = Fork(u, v) = [v, u] + const u = b[0]; + const v = b[1]; + // apply(apply(c, u), v) where c = inner fork + const applied = apply(apply(a[1], u), v); + return applied; + } + + // apply(Leaf, b) = Stem(b) + if (isLeaf(a)) { + return [b]; + } + + // apply(Stem(a), b) = Fork(a, b) + if (isStem(a)) { + return [b, a[0]]; // [right, left] + } + + throw new Error("apply: undefined reduction for terms"); +} diff --git a/ext/js/test/bundle.test.js b/ext/js/test/bundle.test.js @@ -0,0 +1,67 @@ +import { readFileSync } from "node:fs"; +import { strictEqual, ok, throws } from "node:assert"; +import { describe, it } from "node:test"; +import { + parseBundle, + parseManifest, +} from "../src/bundle.js"; +import { + parseNodeSection as bundleParseNodeSection, +} from "../src/bundle.js"; +import { + verifyNodeHashes, + parseNodeSection as parseNodes, +} from "../src/merkle.js"; + +const fixtureDir = "test/fixtures"; + +describe("bundle parsing", () => { + it("valid bundle parses header and sections", () => { + const bundle = parseBundle( + readFileSync(`${fixtureDir}/id.tri.bundle`) + ); + strictEqual(bundle.version, "1.0"); + strictEqual(bundle.sectionCount, 2); + ok(bundle.sections.has(1)); // manifest + ok(bundle.sections.has(2)); // nodes + }); + + it("parseManifest returns valid JSON", () => { + const manifest = parseManifest( + readFileSync(`${fixtureDir}/id.tri.bundle`) + ); + strictEqual(manifest.schema, "arborix.bundle.manifest.v1"); + strictEqual(manifest.bundleType, "tree-calculus-executable-object"); + strictEqual(manifest.closure, "complete"); + strictEqual(manifest.tree.calculus, "tree-calculus.v1"); + strictEqual(manifest.tree.nodeHash.algorithm, "sha256"); + strictEqual(manifest.runtime.semantics, "tree-calculus.v1"); + strictEqual(manifest.runtime.abi, "arborix.abi.tree.v1"); + }); +}); + +describe("hash verification", () => { + it("valid bundle nodes verify", () => { + const data = bundleParseNodeSection( + readFileSync(`${fixtureDir}/id.tri.bundle`) + ); + const { nodeMap } = parseNodes(data); + const { verified } = verifyNodeHashes(nodeMap); + ok(verified, "all node hashes should verify"); + }); +}); + +describe("errors", () => { + it("bad magic fails", () => { + const buf = Buffer.alloc(32, 0); + buf.write("WRONGMAG", 0, 8); + throws(() => parseBundle(buf), /invalid magic/); + }); + + it("unsupported version fails", () => { + const buf = Buffer.alloc(32, 0); + buf.write("ARBORIX\0", 0, 8); + buf.writeUInt16BE(2, 8); // major version 2 + throws(() => parseBundle(buf), /unsupported bundle major version/); + }); +}); diff --git a/ext/js/test/merkle.test.js b/ext/js/test/merkle.test.js @@ -0,0 +1,148 @@ +import { readFileSync } from "node:fs"; +import { strictEqual, ok } from "node:assert"; +import { describe, it } from "node:test"; +import { parseNodeSection } from "../src/bundle.js"; +import { + verifyNodeHashes, + verifyClosure, + verifyRootClosure, + deserializePayload, + computeNodeHash, +} from "../src/merkle.js"; + +describe("merkle — deserializePayload", () => { + it("Leaf (0x00)", () => { + const result = deserializePayload(Buffer.from([0x00])); + strictEqual(result.type, "leaf"); + }); + + it("Stem (0x01 + 32 bytes)", () => { + const childHash = Buffer.alloc(32, 0xab); + const payload = Buffer.concat([Buffer.from([0x01]), childHash]); + const result = deserializePayload(payload); + strictEqual(result.type, "stem"); + strictEqual(result.childHash, "ab".repeat(32)); + }); + + it("Fork (0x02 + 64 bytes)", () => { + const left = Buffer.alloc(32, 0x01); + const right = Buffer.alloc(32, 0x02); + const payload = Buffer.concat([Buffer.from([0x02]), left, right]); + const result = deserializePayload(payload); + strictEqual(result.type, "fork"); + strictEqual(result.leftHash, "01".repeat(32)); + strictEqual(result.rightHash, "02".repeat(32)); + }); + + it("Leaf with extra bytes fails", () => { + throws(() => deserializePayload(Buffer.from([0x00, 0x00])), /invalid leaf/); + }); + + it("Unknown type fails", () => { + throws(() => deserializePayload(Buffer.from([0xff])), /unknown type/); + }); +}); + +describe("merkle — computeNodeHash", () => { + it("Leaf hash is correct length", () => { + const leaf = { type: "leaf" }; + const hash = computeNodeHash(leaf); + strictEqual(hash.length, 64); + }); +}); + +describe("merkle — node section parsing", () => { + const fixtureDir = "test/fixtures"; + + it("parses id.tri.bundle with correct node count", () => { + const data = parseNodeSection( + readFileSync(`${fixtureDir}/id.tri.bundle`) + ); + const { nodeMap } = parseNodes(data); + strictEqual(nodeMap.size, 4); + }); + + it("parses true.tri.bundle with correct node count", () => { + const data = parseNodeSection( + readFileSync(`${fixtureDir}/true.tri.bundle`) + ); + const { nodeMap } = parseNodes(data); + strictEqual(nodeMap.size, 2); + }); +}); + +describe("merkle — hash verification", () => { + const fixtureDir = "test/fixtures"; + + it("id.tri.bundle nodes all verify", () => { + const data = parseNodeSection( + readFileSync(`${fixtureDir}/id.tri.bundle`) + ); + const { nodeMap } = parseNodes(data); + const { verified, mismatches } = verifyNodeHashes(nodeMap); + ok(verified, "id.tri.bundle node hashes should verify"); + strictEqual(mismatches.length, 0); + }); + + it("corrupted node payload fails hash verification", () => { + const data = parseNodeSection( + readFileSync(`${fixtureDir}/id.tri.bundle`) + ); + const { nodeMap } = parseNodes(data); + // Find a stem node to corrupt + let stemKey = null; + for (const [key, node] of nodeMap) { + if (node.type === "stem") { stemKey = key; break; } + } + ok(stemKey, "should find a stem node to corrupt"); + const stem = nodeMap.get(stemKey); + // Corrupt the child hash so serializeNode produces a different payload + const corrupted = { + ...stem, + childHash: "00".repeat(32), + payload: Buffer.concat([Buffer.from([0x01]), Buffer.alloc(32, 0x00)]), + }; + nodeMap.set(stemKey, corrupted); + const { verified, mismatches } = verifyNodeHashes(nodeMap); + ok(!verified, "corrupted stem should fail hash verification"); + ok(mismatches.length > 0, "should have mismatches"); + }); +}); + +describe("merkle — closure verification", () => { + const fixtureDir = "test/fixtures"; + + it("id.tri.bundle has complete closure", () => { + const data = parseNodeSection( + readFileSync(`${fixtureDir}/id.tri.bundle`) + ); + const { nodeMap } = parseNodes(data); + const { complete, missing } = verifyClosure(nodeMap); + ok(complete, "id.tri.bundle should have complete closure"); + strictEqual(missing.length, 0); + }); + + it("verifyRootClosure checks transitive reachability", () => { + const data = parseNodeSection( + readFileSync(`${fixtureDir}/id.tri.bundle`) + ); + const { nodeMap } = parseNodes(data); + const rootHash = "039cc9aacf5be78ec1975713e6ad154a36988e3f3df18589b0d0c801d0825d78"; + const { complete, missingRoots } = verifyRootClosure(nodeMap, rootHash); + ok(complete, "root should be reachable"); + strictEqual(missingRoots.length, 0); + }); +}); + +// Helper import +import { parseNodeSection as parseNodes } from "../src/merkle.js"; + +// Helper for throws +function throws(fn, expected) { + try { + fn(); + return false; + } catch (e) { + return expected.test(e.message); + } +} diff --git a/ext/js/test/reduce.test.js b/ext/js/test/reduce.test.js @@ -0,0 +1,80 @@ +import { strictEqual, ok } from "node:assert"; +import { describe, it } from "node:test"; +import { apply, isLeaf, isStem, isFork } from "../src/tree.js"; +import { reduce } from "../src/cli.js"; + +describe("tree — basic types", () => { + it("Leaf is empty array", () => { + ok(isLeaf([])); + ok(!isStem([])); + ok(!isFork([])); + }); + + it("Stem is single-element array", () => { + ok(isStem([[]])); + ok(!isLeaf([[]])); + }); + + it("Fork is two-element array", () => { + ok(isFork([[], []])); + ok(!isLeaf([[], []])); + }); +}); + +describe("tree — apply rules", () => { + // Leaf = [], Stem = [child], Fork = [right, left] + + it("apply(Leaf, b) = Stem(b)", () => { + const b = []; // Leaf + const result = apply([], b); + ok(isStem(result), "Stem(b) should be a Stem"); + strictEqual(result[0], b); + }); + + it("apply(Stem(a), b) = Fork(a, b)", () => { + const a = []; // Leaf + const b = []; // Leaf + const result = apply([a], b); + ok(isFork(result), "Fork(a, b) should be a Fork"); + // Fork = [right, left] = [b, a] + strictEqual(result[0], b); + strictEqual(result[1], a); + }); + + it("apply(Fork(Leaf, a), _) = a", () => { + // Fork(Leaf, a) = [a, Leaf] + const a = []; // Leaf + const result = apply([a, []], []); + strictEqual(result, a); + ok(isLeaf(result)); + }); +}); + +describe("tree — reduction", () => { + it("reduces Leaf to Leaf", () => { + const result = reduce([], 100); + ok(isLeaf(result)); + }); + + it("reduces Stem Leaf to Stem Leaf", () => { + const result = reduce([[]], 100); + ok(isStem(result)); + ok(isLeaf(result[0])); + }); + + it("reduces Fork Leaf Leaf to Fork Leaf Leaf", () => { + const result = reduce([[], []], 100); + ok(isFork(result)); + ok(isLeaf(result[0])); + ok(isLeaf(result[1])); + }); + + it("S combinator applied to Leaf reduces", () => { + // S = t (t (t t)) t = Fork (Fork (Fork Leaf Leaf) Leaf) Leaf + // In array form: [[[], []], [], []] + const s = [[], [[[], []], []]]; + const leaf = []; + const result = reduce([s, leaf], 100); + ok(Array.isArray(result), "S Leaf should reduce to an array"); + }); +}); diff --git a/ext/js/test/run-bundle.test.js b/ext/js/test/run-bundle.test.js @@ -0,0 +1,84 @@ +import { readFileSync } from "node:fs"; +import { strictEqual, ok, throws } from "node:assert"; +import { describe, it } from "node:test"; +import { parseManifest } from "../src/bundle.js"; +import { parseNodeSection as bundleParseNodeSection } from "../src/bundle.js"; +import { validateManifest, selectExport } from "../src/manifest.js"; +import { verifyNodeHashes, parseNodeSection as parseNodes } from "../src/merkle.js"; +import { buildTreeFromNodeMap } from "../src/cli.js"; + +const fixtureDir = "test/fixtures"; + +describe("run bundle — id.tri.bundle", () => { + const bundle = readFileSync(`${fixtureDir}/id.tri.bundle`); + const manifest = parseManifest(bundle); + const nodeSectionData = bundleParseNodeSection(bundle); + const { nodeMap } = parseNodes(nodeSectionData); + + it("manifest validates", () => { + validateManifest(manifest); + }); + + it("node hashes verify", () => { + const { verified } = verifyNodeHashes(nodeMap); + ok(verified); + }); + + it("export 'id' is selectable", () => { + const exp = selectExport(manifest, "id"); + strictEqual(exp.name, "id"); + }); + + it("tree reconstructs as a Fork", () => { + const exp = selectExport(manifest, "id"); + const tree = buildTreeFromNodeMap(nodeMap, exp.root); + ok(Array.isArray(tree)); + // id = t (t t) = Fork (Stem Leaf) Leaf... + // In Haskell: id = S = t (t (t t)) t + // This is Fork (Fork (Fork Leaf Leaf) Leaf) Leaf + // In array form: [[[], []], [], []] + ok(tree.length >= 2, "tree should be a Fork (length >= 2)"); + }); +}); + +describe("run bundle — true.tri.bundle", () => { + const bundle = readFileSync(`${fixtureDir}/true.tri.bundle`); + const manifest = parseManifest(bundle); + const nodeSectionData = bundleParseNodeSection(bundle); + const { nodeMap } = parseNodes(nodeSectionData); + + it("manifest validates", () => { + validateManifest(manifest); + }); + + it("export 'const' is selectable", () => { + const exp = selectExport(manifest, "const"); + strictEqual(exp.name, "const"); + }); + + it("tree reconstructs", () => { + const exp = selectExport(manifest, "const"); + const tree = buildTreeFromNodeMap(nodeMap, exp.root); + ok(Array.isArray(tree)); + }); +}); + +describe("run bundle — missing export", () => { + const bundle = readFileSync(`${fixtureDir}/id.tri.bundle`); + const manifest = parseManifest(bundle); + + it("nonexistent export fails clearly", () => { + throws(() => selectExport(manifest, "nonexistent"), /not found/); + }); +}); + +describe("run bundle — auto-select", () => { + // true.tri.bundle has only one export, should auto-select + const bundle = readFileSync(`${fixtureDir}/true.tri.bundle`); + const manifest = parseManifest(bundle); + + it("single export auto-selects", () => { + const exp = selectExport(manifest, undefined); + ok(exp, "should auto-select the only export"); + }); +}); diff --git a/src/ContentStore.hs b/src/ContentStore.hs @@ -4,11 +4,14 @@ import Research import Control.Monad (foldM, forM_, void) import Data.ByteString (ByteString) +import Data.Char (isHexDigit) import Data.List (nub, sort) import Data.Maybe (catMaybes, fromMaybe) import Data.Text (Text) import Database.SQLite.Simple import System.Directory (createDirectoryIfMissing, getXdgDirectory, XdgDirectory(..)) +import System.Environment (lookupEnv) +import System.Exit (die) import System.FilePath ((</>), takeDirectory) import qualified Data.Map as Map @@ -41,6 +44,13 @@ initContentStore = do dbPath <- getContentStorePath createDirectoryIfMissing True (takeDirectory dbPath) conn <- open dbPath + setupDatabase conn + return conn + +-- | Initialise a database connection (file-backed or in-memory). +-- This is factored out so tests can reuse it with ":memory:". +setupDatabase :: Connection -> IO () +setupDatabase conn = do execute_ conn "CREATE TABLE IF NOT EXISTS terms (\ \hash TEXT PRIMARY KEY, \ \names TEXT, \ @@ -52,12 +62,24 @@ initContentStore = do execute_ conn "CREATE TABLE IF NOT EXISTS merkle_nodes (\ \hash TEXT PRIMARY KEY, \ \node_data BLOB NOT NULL)" - return conn + -- Seed canonical Leaf node payload (0x00) + putMerkleNode conn NLeaf + +-- | Create an in-memory ContentStore connection (for tests). +newContentStore :: IO Connection +newContentStore = do + conn <- open ":memory:" + setupDatabase conn + return conn getContentStorePath :: IO FilePath getContentStorePath = do - dataDir <- getXdgDirectory XdgData "tricu" - return $ dataDir </> "content-store.db" + maybeLocalPath <- lookupEnv "TRICU_DB_PATH" + case maybeLocalPath of + Just p -> return p + Nothing -> do + dataDir <- getXdgDirectory XdgData "tricu" + return $ dataDir </> "content-store.db" @@ -95,13 +117,11 @@ storeTerm conn newNamesStrList term = do -- | Reconstruct a Tree Calculus term from its Merkle root hash. -- Recursively loads nodes and rebuilds the T structure. loadTree :: Connection -> MerkleHash -> IO (Maybe T) -loadTree conn h - | h == nodeHash NLeaf = return (Just Leaf) -- NLeaf is implicit, not stored - | otherwise = do - maybeNode <- getNodeMerkle conn h - case maybeNode of - Nothing -> return Nothing - Just node -> Just <$> buildTree node +loadTree conn h = do + maybeNode <- getNodeMerkle conn h + case maybeNode of + Nothing -> return Nothing + Just node -> Just <$> buildTree node where buildTree :: Node -> IO T buildTree NLeaf = return Leaf @@ -116,7 +136,9 @@ loadTree conn h -- | Store all nodes of a Merkle DAG by traversing the Term and building/storing nodes. -- Returns the hash of the root node. storeMerkleNodes :: Connection -> T -> IO MerkleHash -storeMerkleNodes _ Leaf = return $ nodeHash NLeaf +storeMerkleNodes conn Leaf = do + putMerkleNode conn NLeaf + return $ nodeHash NLeaf storeMerkleNodes conn (Stem t) = do childHash <- storeMerkleNodes conn t let thisNode = NStem childHash @@ -252,3 +274,36 @@ queryMaybeOne conn qry params = do case results of [row] -> return $ Just row _ -> return Nothing + +-- | Resolve a user-supplied identifier (full/prefix hash, term name) to +-- a single term hash and the list of names bound to it. Dies on +-- ambiguity or missing term (matching the CLI @export@ semantics). +resolveExportTarget :: Connection -> String -> IO (Text, [Text]) +resolveExportTarget conn input = do + let raw = T.pack $ dropWhile (== '#') input + byName <- query conn + "SELECT hash FROM terms WHERE (names = ? OR names LIKE ? OR names LIKE ? OR names LIKE ?) ORDER BY created_at DESC" + (raw, raw <> T.pack ",%", T.pack "," <> raw <> T.pack ",%", T.pack "%," <> raw) :: IO [Only T.Text] + case byName of + [Only fullHash] -> namesForHash conn fullHash >>= \names -> return (fullHash, names) + (_:_) -> die $ "Ambiguous term name: " ++ input + [] -> do + byHash <- query conn "SELECT hash FROM terms WHERE hash LIKE ? ORDER BY created_at DESC" + (Only (raw <> T.pack "%")) :: IO [Only T.Text] + case byHash of + [Only fullHash] -> namesForHash conn fullHash >>= \names -> return (fullHash, names) + [] -> if looksLikeHash raw + then return (raw, []) + else die $ "No term found matching: " ++ input + _ -> die $ "Ambiguous hash prefix: " ++ input + +namesForHash :: Connection -> Text -> IO [Text] +namesForHash conn h = do + stored <- hashToTerm conn h + return $ maybe [] (parseNameList . termNames) stored + +-- | Return 'True' when @t@ looks like a full or partial SHA-256 hex hash. +looksLikeHash :: Text -> Bool +looksLikeHash t = + let len = T.length t + in len >= 16 && len <= 64 && T.all isHexDigit t diff --git a/src/FileEval.hs b/src/FileEval.hs @@ -1,18 +1,30 @@ -module FileEval where - -import Eval +module FileEval + ( preprocessFile + , evaluateFile + , evaluateFileWithContext + , evaluateFileResult + , compileFile + ) where + +import Eval (evalTricu) import Lexer import Parser import Research +import ContentStore (initContentStore, storeTerm, hashTerm) +import Wire (exportNamedBundle) import Control.Monad () import Data.List (partition) -import Data.Maybe (mapMaybe) +import Data.Maybe (fromMaybe, mapMaybe) +import System.Environment (setEnv) import System.FilePath (takeDirectory, normalise, (</>)) -import System.IO () +import System.Exit (die) +import Database.SQLite.Simple (close) +import qualified Data.ByteString.Lazy as BL import qualified Data.Map as Map import qualified Data.Set as Set +import qualified Data.Text as T extractMain :: Env -> Either String T extractMain env = @@ -96,9 +108,6 @@ makeRelativeTo f i = let d = takeDirectory f in normalise $ d </> i -nsDefinitions :: String -> [TricuAST] -> [TricuAST] -nsDefinitions moduleName = map (nsDefinition moduleName) - nsDefinition :: String -> TricuAST -> TricuAST nsDefinition "" def = def nsDefinition moduleName (SDef name args body) @@ -152,3 +161,26 @@ isPrefixed name = '.' `elem` name nsVariable :: String -> String -> String nsVariable "" name = name nsVariable moduleName name = moduleName ++ "." ++ name + +-- | Compile a tricu source file to a standalone Arborix bundle. +-- Uses a temp content store so it does not collide with the global one. +compileFile :: FilePath -> FilePath -> Maybe T.Text -> IO () +compileFile inputPath outputPath maybeExportName = do + -- Evaluate the file to get the full environment + env <- evaluateFile inputPath + -- Look up the export name: prefer explicit, then fall back to "main" + let name = fromMaybe "main" (T.unpack <$> maybeExportName) + case Map.lookup name env of + Nothing -> die $ "No definition '" ++ name ++ "' found in " ++ inputPath + Just term -> do + -- Create a temp content store + setEnv "TRICU_DB_PATH" "/tmp/tricu-compile.db" + conn <- initContentStore + -- Store the term in the temp store + _ <- storeTerm conn [name] term + -- Export the bundle (exportNamedBundle returns already-encoded bytes) + bundleData <- exportNamedBundle conn [(T.pack name, hashTerm term)] + BL.writeFile outputPath (BL.fromStrict bundleData) + close conn + putStrLn $ "Compiled " ++ inputPath ++ " -> " ++ outputPath + putStrLn $ " export: " ++ name diff --git a/src/Main.hs b/src/Main.hs @@ -1,25 +1,37 @@ module Main where -import ContentStore () +import ContentStore (initContentStore, loadEnvironment, resolveExportTarget) +import Server (runServer) import Eval (evalTricu, mainResult, result) import FileEval import Parser (parseTricu) import REPL import Research +import Wire import Control.Monad (foldM) -import Control.Monad.IO.Class () +import Data.Text (Text, unpack) +import qualified Data.Text as T import Data.Version (showVersion) import Paths_tricu (version) import System.Console.CmdArgs +import System.Environment (lookupEnv) +import System.IO (hPutStrLn, stderr) import Text.Megaparsec () +import qualified Data.ByteString.Lazy as BL +import Database.SQLite.Simple (close) + import qualified Data.Map as Map data TricuArgs = Repl | Evaluate { file :: [FilePath], form :: EvaluatedForm } | TDecode { file :: [FilePath] } + | Compile { inputFile :: FilePath, outFile :: FilePath, exportNameOpt :: String } + | Export { hash :: String, exportNameOpt :: String, outFile :: FilePath } + | Import { inFile :: FilePath } + | Serve { host :: String, port :: Int } deriving (Show, Data, Typeable) replMode :: TricuArgs @@ -53,10 +65,53 @@ decodeMode = TDecode &= explicit &= name "decode" +exportMode :: TricuArgs +exportMode = Export + { hash = def &= help "Full/prefix hash or stored term name to export." + &= name "h" &= typ "HASH_OR_NAME" + , exportNameOpt = def &= help "Export name to place in the bundle manifest. Defaults to the stored term name when exporting by name; otherwise defaults to root." + &= name "n" &= typ "NAME" + , outFile = def &= help "Output file path for the bundle." &= name "o" &= typ "FILE" + } + &= help "Export a Merkle bundle from the content store." + &= explicit + &= name "export" + +importMode :: TricuArgs +importMode = Import + { inFile = def &= help "Path to the bundle file to import." + &= name "f" &= typ "FILE" + } + &= help "Import a Merkle bundle into the content store." + &= explicit + &= name "import" + +compileMode :: TricuArgs +compileMode = Compile + { inputFile = def &= help "Path to the tricu source file (.tri) to compile." + &= name "f" &= typ "FILE" + , outFile = def &= help "Output bundle file path (.tri.bundle)." + &= name "o" &= typ "FILE" + , exportNameOpt = def &= help "Definition name to use as the bundle root. Defaults to 'main'." + &= name "x" &= typ "NAME" + } + &= help "Compile a tricu source file into a standalone Arborix portable bundle." + &= explicit + &= name "compile" + +serveMode :: TricuArgs +serveMode = Serve + { host = "127.0.0.1" &= help "Host to bind the server to." &= name "h" &= typ "HOST" + , port = 8787 &= help "HTTP port to listen on." &= name "p" &= typ "PORT" + } + &= help "Start a read-only HTTP server for exporting Arborix bundles." + &= explicit + &= name "server" + main :: IO () main = do let versionStr = "tricu Evaluator and REPL " ++ showVersion version - cmdArgsParsed <- cmdArgs $ modes [replMode, evaluateMode, decodeMode] + cmdArgsParsed <- cmdArgs $ modes [replMode, evaluateMode, decodeMode, compileMode, exportMode, importMode, serveMode] &= help "tricu: Exploring Tree Calculus" &= program "tricu" &= summary versionStr @@ -67,10 +122,26 @@ main = do putStrLn "You may exit with `CTRL+D` or the `!exit` command." repl Evaluate { file = filePaths, form = outputForm } -> do + maybeDbPath <- lookupEnv "TRICU_DB_PATH" evalResult <- case filePaths of - [] -> runTricuT <$> getContents - (filePath:restFilePaths) -> do - initialEnv <- evaluateFile filePath + [] -> do + initialEnv <- case maybeDbPath of + Just _ -> do + conn <- initContentStore + env <- loadEnvironment conn + close conn + return env + Nothing -> return Map.empty + input <- getContents + pure $ runTricuTEnv initialEnv input + (_:restFilePaths) -> do + initialEnv <- case maybeDbPath of + Just _ -> do + conn <- initContentStore + env <- loadEnvironment conn + close conn + return env + Nothing -> return Map.empty finalEnv <- foldM evaluateFileWithContext initialEnv restFilePaths pure $ mainResult finalEnv let fRes = formatT outputForm evalResult @@ -80,6 +151,30 @@ main = do [] -> getContents (filePath:_) -> readFile filePath putStrLn $ decodeResult $ result $ evalTricu Map.empty $ parseTricu value + Export { hash = hashStr, exportNameOpt = exportNameArg, outFile = outFilePath } -> do + conn <- initContentStore + (resolvedHash, storedNames) <- resolveExportTarget conn hashStr + expName <- chooseExportName exportNameArg hashStr storedNames + bundleData <- exportNamedBundle conn [(expName, resolvedHash)] + BL.writeFile outFilePath (BL.fromStrict bundleData) + putStrLn $ "Exported bundle export " ++ unpack expName ++ " -> " ++ unpack resolvedHash ++ " to " ++ outFilePath + close conn + Import { inFile = importFile } -> do + conn <- initContentStore + bundleData <- BL.readFile importFile + roots <- importBundle conn (BL.toStrict bundleData) + putStrLn $ "Imported " ++ show (length roots) ++ " root(s):" + mapM_ (\r -> putStrLn $ " " ++ unpack r) roots + close conn + Compile { inputFile = compileInputFile, outFile = compileOutFile, exportNameOpt = exportNameArg } -> + let bundleExportName = if null exportNameArg then Nothing else Just (T.pack exportNameArg) + in compileFile compileInputFile compileOutFile bundleExportName + Serve { host = hostStr, port = portNum } -> do + putStrLn $ "Starting Arborix bundle server on " ++ hostStr ++ ":" ++ show portNum + putStrLn $ " GET /bundle/hash/:hash — primary endpoint" + putStrLn $ " GET /bundle/name/:name — convenience endpoint" + putStrLn $ " Content-Type: application/vnd.arborix.bundle" + runServer hostStr portNum runTricu :: String -> String runTricu = formatT TreeCalculus . runTricuT @@ -124,3 +219,21 @@ runTricuEnvWithEnv env input = finalEnv = evalTricu env asts res = result finalEnv in (finalEnv, formatT TreeCalculus res) + +chooseExportName :: String -> String -> [Text] -> IO Text +chooseExportName explicitName input storedNames + | not (null explicitName) = return $ T.pack explicitName + | Just firstName <- firstNonEmpty storedNames = return firstName + | otherwise = do + hPutStrLn stderr $ + "No stored name found for export target " ++ input ++ "; using export name 'root'. " + ++ "Use export -n NAME to preserve a semantic name." + return "root" + +firstNonEmpty :: [Text] -> Maybe Text +firstNonEmpty = go + where + go [] = Nothing + go (x:xs) + | T.null x = go xs + | otherwise = Just x diff --git a/src/REPL.hs b/src/REPL.hs @@ -6,6 +6,7 @@ import FileEval import Lexer () import Parser import Research +import Wire import Control.Concurrent (forkIO, threadDelay, killThread, ThreadId) import Control.Exception (SomeException, catch, displayException) @@ -17,6 +18,8 @@ import Control.Monad.Trans.Class () import Control.Monad.Trans.Maybe (MaybeT(..), runMaybeT) import Data.ByteString () import Data.Char (isSpace) + +import qualified Data.ByteString.Lazy as BL import Data.IORef (newIORef, readIORef, writeIORef) import Data.List (dropWhileEnd, isPrefixOf, find) import Data.Maybe (isJust, fromJust) @@ -73,6 +76,8 @@ repl = do , "!versions" , "!select" , "!tag" + , "!export" + , "!bundleimport" ] loop :: REPLState -> InputT IO () @@ -103,6 +108,8 @@ repl = do outputStrLn " !versions - Show all versions of a term by name" outputStrLn " !select - Select a specific version of a term for subsequent lookups" outputStrLn " !tag - Add or update a tag for a term by hash or name" + outputStrLn " !export - Export a term bundle to file (hash, file)" + outputStrLn " !bundleimport- Import a bundle file into the content store" loop state | strip s == "!output" -> handleOutput state | strip s == "!definitions" -> handleDefinitions state @@ -112,6 +119,8 @@ repl = do | "!versions" `isPrefixOf` strip s -> handleVersions state | "!select" `isPrefixOf` strip s -> handleSelect state | "!tag" `isPrefixOf` strip s -> handleTag state + | "!export" `isPrefixOf` strip s -> handleExport state + | "!bundleimport" `isPrefixOf` strip s -> handleBundleImport state | take 2 s == "--" -> loop state | otherwise -> do evalResult <- liftIO $ catch @@ -438,6 +447,74 @@ repl = do then do printError $ "No versions found for term name: " ++ ident; return Nothing else return $ Just $ (\(h,_,_) -> h) $ head versions + handleExport :: REPLState -> InputT IO () + handleExport state = do + let fset = setComplete completeFilename defaultSettings + hashInput <- runInputT fset $ getInputLineWithInitial "Hash or name: " ("", "") + case hashInput of + Nothing -> loop state + Just hashStr -> do + fileInput <- runInputT fset $ getInputLineWithInitial "Output file: " ("", "") + case fileInput of + Nothing -> loop state + Just outFile -> case replContentStore state of + Nothing -> do + liftIO $ printError "Content store not initialized" + loop state + Just conn -> do + let cleanHash = strip hashStr + hash <- liftIO $ do + let h = T.pack cleanHash + if '#' `T.elem` h + then return h + else do + results <- query conn "SELECT hash FROM terms WHERE names LIKE ? LIMIT 1" + (Only (h <> "%")) :: IO [Only T.Text] + case results of + [Only fullHash] -> return fullHash + [] -> do + results2 <- query conn "SELECT hash FROM terms WHERE hash LIKE ? LIMIT 1" + (Only (h <> "%")) :: IO [Only T.Text] + case results2 of + [Only fullHash] -> return fullHash + _ -> do + printError $ "No term found matching: " ++ cleanHash + return h + _ -> do + printError $ "Ambiguous match for: " ++ cleanHash + return h + bundleData <- liftIO $ exportBundle conn [hash] + liftIO $ BL.writeFile outFile (BL.fromStrict bundleData) + liftIO $ do + printSuccess $ "Exported bundle with root " + displayColoredHash hash + putStrLn $ " to " ++ outFile + loop state + + handleBundleImport :: REPLState -> InputT IO () + handleBundleImport state = do + let fset = setComplete completeFilename defaultSettings + fileInput <- runInputT fset $ getInputLineWithInitial "Bundle file: " ("", "") + case fileInput of + Nothing -> loop state + Just inFile -> case replContentStore state of + Nothing -> do + liftIO $ printError "Content store not initialized" + loop state + Just conn -> do + exists <- liftIO $ doesFileExist inFile + if not exists + then do + liftIO $ printError $ "File not found: " ++ inFile + loop state + else do + bundleData <- liftIO $ BL.readFile inFile + roots <- liftIO $ importBundle conn (BL.toStrict bundleData) + liftIO $ do + printSuccess $ "Imported " ++ show (length roots) ++ " root(s):" + mapM_ (\r -> putStrLn $ " " ++ T.unpack r) roots + loop state + interruptHandler :: REPLState -> Interrupt -> InputT IO () interruptHandler state _ = do liftIO $ do diff --git a/src/Server.hs b/src/Server.hs @@ -0,0 +1,176 @@ +module Server + ( runServer + ) where + +import ContentStore (initContentStore, nameToTerm, hashToTerm, listStoredTerms, + parseNameList, StoredTerm(..), termHash) +import Database.SQLite.Simple (close) +import Wire (exportNamedBundle) + +import Network.HTTP.Types (Header, Status, status200, status400, status404, status405, hContentType) +import Network.Wai +import Network.Wai.Handler.Warp (defaultSettings, runSettings, setHost, setPort) + +import Data.String (fromString) +import Data.Text (Text) +import Data.Text.Encoding (encodeUtf8) +import Data.Char (isHexDigit) +import Data.ByteString.Lazy (fromStrict) + +import qualified Data.Text as T + +-- | Start an HTTP server that serves Arborix bundles from the +-- local content store. +-- +-- This is a read-only export surface. Clients fetch bundle bytes +-- and independently inspect / verify / run them. The server does +-- not execute bundles. +-- +-- Bind host defaults to @127.0.0.1@. +-- +-- Endpoints +-- --------- +-- GET /health — 200 "ok" +-- GET /bundle/name/:name — export by stored term name +-- GET /bundle/hash/:hash — export by full Merkle hash +-- GET /terms — plain-text listing (debug) +-- +runServer :: String -> Int -> IO () +runServer hostStr port = + runSettings settings app + where + settings = setPort port $ setHost (fromString hostStr) defaultSettings + +-- | WAI application backed by the content store. +-- Uses the same database path as @eval@ mode (env var +-- @TRICU_DB_PATH@ or the default location). +app :: Application +app request respond = case (requestMethod request, pathInfo request) of + ("GET", ["health"]) -> + respond $ healthResponse + + ("GET", ["bundle", "name", nameText]) -> do + body <- nameHandler nameText + respond body + + ("GET", ["bundle", "hash", hashText]) -> do + body <- hashHandler hashText + respond body + + ("GET", ["terms"]) -> do + body <- termsResponse + respond body + + ("POST", _) -> + respond $ responseLBS status405 [] "Method not allowed" + + ("PUT", _) -> + respond $ responseLBS status405 [] "Method not allowed" + + ("DELETE", _) -> + respond $ responseLBS status405 [] "Method not allowed" + + _ -> + respond $ responseLBS status404 [] "not found" + +healthResponse :: Response +healthResponse = responseLBS status200 [] "ok" + +-- | GET /bundle/name/:name +-- Resolve a stored term name, export it as an Arborix bundle, +-- and return the raw bundle bytes. +-- +-- Sets @Content-Type@ and @X-Arborix-Root-Hash@ headers. +-- Returns 404 when the name does not resolve to any stored term. +nameHandler :: Text -> IO Response +nameHandler nameText = do + conn <- initContentStore + stored <- nameToTerm conn nameText + case stored of + Nothing -> do + close conn + return $ textResponse status404 ("not found: " <> nameText) + Just term' -> do + let th = termHash term' + namedHashes = [(firstOrRoot (termNames term'), th)] + bundleData <- exportNamedBundle conn namedHashes + let cd = T.pack $ "attachment; filename=" ++ safeFileName (T.unpack nameText) ++ ".bundle" + close conn + return $ responseLBS status200 (bundleHeaders th cd) (fromStrict bundleData) + +-- | GET /bundle/hash/:hash +-- Resolve a full Merkle hash and export the root as an Arborix +-- bundle. +-- +-- - Malformed hash (non-hex or < 16 chars): 400 +-- - Well-formed but absent: 404 +-- - Present: 200 with bundle bytes +hashHandler :: Text -> IO Response +hashHandler hashText = + let raw = T.pack (dropWhile (== '#') (T.unpack hashText)) + in if not (T.all isHexDigit raw) || T.length raw < 16 + then return $ responseLBS status400 [] "400 Bad Request: invalid hash" + else do + conn <- initContentStore + stored <- hashToTerm conn raw + case stored of + Nothing -> do + close conn + return $ textResponse status404 ("not found: " <> hashText) + Just term' -> do + let th = termHash term' + namedHashes' = [(firstOrRoot (termNames term'), th)] + bundleData <- exportNamedBundle conn namedHashes' + close conn + return $ responseLBS status200 + (bundleHeaders th "attachment; filename=hash.bundle") + (fromStrict bundleData) + +-- | GET /terms +-- Plain-text listing of all stored terms (debugging only). +termsResponse :: IO Response +termsResponse = do + conn <- initContentStore + terms <- listStoredTerms conn + close conn + let lines' = [ names <> " " <> hash <> " " <> T.pack (show created) + | term <- terms + , let names = termNames term + , let hash = termHash term + , let created = termCreatedAt term ] + return $ responseLBS status200 + [ (hContentType, encodeUtf8 "text/plain; charset=utf-8") + ] + (fromStrict $ encodeUtf8 $ T.unlines lines') + +textResponse :: Status -> Text -> Response +textResponse status body = + responseLBS status + [ (hContentType, encodeUtf8 "text/plain; charset=utf-8") ] + (fromStrict $ encodeUtf8 body) + +bundleHeaders :: Text -> Text -> [Header] +bundleHeaders root cd = + [ (hContentType, encodeUtf8 "application/vnd.arborix.bundle") + , ("X-Arborix-Root-Hash", encodeUtf8 root) + , ("Content-Disposition", encodeUtf8 cd) + ] + +-- | Pick the first stored name, falling back to "root" when names are empty. +firstOrRoot :: Text -> Text +firstOrRoot names = + case parseNameList names of + [] -> "root" + (x:_) -> x + +-- | Sanitise a string to a safe filename prefix. +safeFileName :: String -> String +safeFileName = map go + where + go c + | c >= 'a' && c <= 'z' = c + | c >= 'A' && c <= 'Z' = c + | c >= '0' && c <= '9' = c + | c == '-' = c + | c == '_' = c + | otherwise = '_' diff --git a/src/Wire.hs b/src/Wire.hs @@ -0,0 +1,809 @@ +{-# LANGUAGE DeriveGeneric #-} + +module Wire + ( Bundle (..) + , BundleManifest (..) + , TreeSpec (..) + , NodeHashSpec (..) + , RuntimeSpec (..) + , BundleRoot (..) + , BundleExport (..) + , BundleMetadata (..) + , ClosureMode (..) + , encodeBundle + , decodeBundle + , verifyBundle + , collectReachableNodes + , exportBundle + , exportNamedBundle + , importBundle + ) where + +import ContentStore (getNodeMerkle, loadTree, putMerkleNode, storeTerm) +import Research + +import Control.Exception (SomeException, evaluate, try) +import Control.Monad (foldM, unless, when) +import Crypto.Hash (Digest, SHA256, hash) +import Data.Aeson ( FromJSON (..) + , ToJSON (..) + , Value (String) + , eitherDecodeStrict' + , encode + , object + , withObject + , (.:) + , (.:?) + , (.!=) + , (.=) + ) +import Data.Bits ((.&.), (.|.), shiftL, shiftR) +import Data.ByteArray (convert) +import Data.ByteString (ByteString) +import Data.Foldable (traverse_) +import Data.Map (Map) +import Data.Text (Text, unpack) +import Data.Text.Encoding (decodeUtf8, encodeUtf8) +import Data.Word (Word16, Word32, Word64) +import Database.SQLite.Simple (Connection) +import GHC.Generics (Generic) + +import qualified Data.ByteString as BS +import qualified Data.ByteString.Base16 as Base16 +import qualified Data.ByteString.Lazy as BL +import qualified Data.Map as Map +import qualified Data.Set as Set +import qualified Data.Text as T + +-- | Portable bundle major/minor version supported by this module. +bundleMajorVersion :: Word16 +bundleMajorVersion = 1 + +bundleMinorVersion :: Word16 +bundleMinorVersion = 0 + +-- | Header magic for the portable executable-object container. +bundleMagic :: ByteString +bundleMagic = BS.pack [0x41, 0x52, 0x42, 0x4f, 0x52, 0x49, 0x58, 0x00] -- "ARBORIX\0" + +headerLength :: Int +headerLength = 32 + +sectionEntryLength :: Int +sectionEntryLength = 60 + +sectionManifest, sectionNodes :: Word32 +sectionManifest = 1 +sectionNodes = 2 + +flagCritical :: Word16 +flagCritical = 0x0001 + +compressionNone, digestSha256 :: Word16 +compressionNone = 0 +digestSha256 = 1 + +-- | Closure declaration. V1 only accepts complete bundles for import. +data ClosureMode = ClosureComplete | ClosurePartial + deriving (Show, Eq, Ord, Generic) + +instance ToJSON ClosureMode where + toJSON ClosureComplete = String "complete" + toJSON ClosurePartial = String "partial" + +instance FromJSON ClosureMode where + parseJSON (String "complete") = pure ClosureComplete + parseJSON (String "partial") = pure ClosurePartial + parseJSON _ = fail "closure must be \"complete\" or \"partial\"" + +data NodeHashSpec = NodeHashSpec + { nodeHashAlgorithm :: Text + , nodeHashDomain :: Text + } deriving (Show, Eq, Ord, Generic) + +instance ToJSON NodeHashSpec where + toJSON s = object + [ "algorithm" .= nodeHashAlgorithm s + , "domain" .= nodeHashDomain s + ] + +instance FromJSON NodeHashSpec where + parseJSON = withObject "NodeHashSpec" $ \o -> NodeHashSpec + <$> o .: "algorithm" + <*> o .: "domain" + +data TreeSpec = TreeSpec + { treeCalculus :: Text + , treeNodeHash :: NodeHashSpec + , treeNodePayload :: Text + } deriving (Show, Eq, Ord, Generic) + +instance ToJSON TreeSpec where + toJSON s = object + [ "calculus" .= treeCalculus s + , "nodeHash" .= treeNodeHash s + , "nodePayload" .= treeNodePayload s + ] + +instance FromJSON TreeSpec where + parseJSON = withObject "TreeSpec" $ \o -> TreeSpec + <$> o .: "calculus" + <*> o .: "nodeHash" + <*> o .: "nodePayload" + +data RuntimeSpec = RuntimeSpec + { runtimeSemantics :: Text + , runtimeEvaluation :: Text + , runtimeAbi :: Text + , runtimeCapabilities :: [Text] + } deriving (Show, Eq, Ord, Generic) + +instance ToJSON RuntimeSpec where + toJSON s = object + [ "semantics" .= runtimeSemantics s + , "evaluation" .= runtimeEvaluation s + , "abi" .= runtimeAbi s + , "capabilities" .= runtimeCapabilities s + ] + +instance FromJSON RuntimeSpec where + parseJSON = withObject "RuntimeSpec" $ \o -> RuntimeSpec + <$> o .: "semantics" + <*> o .: "evaluation" + <*> o .: "abi" + <*> o .:? "capabilities" .!= [] + +data BundleRoot = BundleRoot + { rootHash :: MerkleHash + , rootRole :: Text + } deriving (Show, Eq, Ord, Generic) + +instance ToJSON BundleRoot where + toJSON r = object + [ "hash" .= rootHash r + , "role" .= rootRole r + ] + +instance FromJSON BundleRoot where + parseJSON = withObject "BundleRoot" $ \o -> BundleRoot + <$> o .: "hash" + <*> o .:? "role" .!= "root" + +data BundleExport = BundleExport + { exportName :: Text + , exportRoot :: MerkleHash + , exportKind :: Text + , exportAbi :: Text + , exportInput :: Maybe Text + , exportOutput :: Maybe Text + } deriving (Show, Eq, Ord, Generic) + +instance ToJSON BundleExport where + toJSON e = object + [ "name" .= exportName e + , "root" .= exportRoot e + , "kind" .= exportKind e + , "abi" .= exportAbi e + , "input" .= exportInput e + , "output" .= exportOutput e + ] + +instance FromJSON BundleExport where + parseJSON = withObject "BundleExport" $ \o -> BundleExport + <$> o .: "name" + <*> o .: "root" + <*> o .:? "kind" .!= "term" + <*> o .:? "abi" .!= "arborix.abi.tree.v1" + <*> o .:? "input" + <*> o .:? "output" + +data BundleMetadata = BundleMetadata + { metadataPackage :: Maybe Text + , metadataVersion :: Maybe Text + , metadataDescription :: Maybe Text + , metadataLicense :: Maybe Text + , metadataCreatedBy :: Maybe Text + } deriving (Show, Eq, Ord, Generic) + +instance ToJSON BundleMetadata where + toJSON m = object + [ "package" .= metadataPackage m + , "version" .= metadataVersion m + , "description" .= metadataDescription m + , "license" .= metadataLicense m + , "createdBy" .= metadataCreatedBy m + ] + +instance FromJSON BundleMetadata where + parseJSON = withObject "BundleMetadata" $ \o -> BundleMetadata + <$> o .:? "package" + <*> o .:? "version" + <*> o .:? "description" + <*> o .:? "license" + <*> o .:? "createdBy" + +data BundleManifest = BundleManifest + { manifestSchema :: Text + , manifestBundleType :: Text + , manifestTree :: TreeSpec + , manifestRuntime :: RuntimeSpec + , manifestClosure :: ClosureMode + , manifestRoots :: [BundleRoot] + , manifestExports :: [BundleExport] + , manifestImports :: [Value] + , manifestSections :: Value + , manifestMetadata :: BundleMetadata + } deriving (Show, Eq, Generic) + +instance ToJSON BundleManifest where + toJSON m = object + [ "schema" .= manifestSchema m + , "bundleType" .= manifestBundleType m + , "tree" .= manifestTree m + , "runtime" .= manifestRuntime m + , "closure" .= manifestClosure m + , "roots" .= manifestRoots m + , "exports" .= manifestExports m + , "imports" .= manifestImports m + , "sections" .= manifestSections m + , "metadata" .= manifestMetadata m + ] + +instance FromJSON BundleManifest where + parseJSON = withObject "BundleManifest" $ \o -> BundleManifest + <$> o .: "schema" + <*> o .: "bundleType" + <*> o .: "tree" + <*> o .: "runtime" + <*> o .: "closure" + <*> o .: "roots" + <*> o .: "exports" + <*> o .:? "imports" .!= [] + <*> o .:? "sections" .!= object [] + <*> o .:? "metadata" .!= BundleMetadata Nothing Nothing Nothing Nothing Nothing + +-- | Portable executable-object bundle. +-- +-- Merkle node payloads remain the language-neutral executable core: +-- Leaf = 0x00; Stem = 0x01 || child_hash; Fork = 0x02 || left_hash || right_hash. +-- Names, exports, runtime metadata, and package metadata live in the manifest layer. +data Bundle = Bundle + { bundleVersion :: Word16 + , bundleRoots :: [MerkleHash] + , bundleNodes :: Map MerkleHash ByteString + , bundleManifest :: BundleManifest + , bundleManifestBytes :: ByteString + } deriving (Show, Eq) + +-- | Encode a Bundle to portable Bundle v1 bytes. +encodeBundle :: Bundle -> ByteString +encodeBundle bundle = + let nodeSection = encodeNodeSection (bundleNodes bundle) + manifestBytes = if BS.null (bundleManifestBytes bundle) + then BL.toStrict (encode (bundleManifest bundle)) + else bundleManifestBytes bundle + sectionCount = 2 + dirOffset = fromIntegral headerLength + sectionDirLength = sectionCount * sectionEntryLength + manifestOffset = fromIntegral (headerLength + sectionDirLength) + nodesOffset = manifestOffset + fromIntegral (BS.length manifestBytes) + manifestEntry = encodeSectionEntry sectionManifest 1 flagCritical compressionNone + manifestOffset (fromIntegral $ BS.length manifestBytes) manifestBytes + nodesEntry = encodeSectionEntry sectionNodes 1 flagCritical compressionNone + nodesOffset (fromIntegral $ BS.length nodeSection) nodeSection + header = encodeHeader bundleMajorVersion bundleMinorVersion + (fromIntegral sectionCount) 0 dirOffset + in header <> manifestEntry <> nodesEntry <> manifestBytes <> nodeSection + +-- | Decode portable Bundle v1 bytes. +decodeBundle :: ByteString -> Either String Bundle +decodeBundle bs + | BS.take (BS.length bundleMagic) bs == bundleMagic = decodePortableBundle bs + | otherwise = Left "invalid magic" + +-- --------------------------------------------------------------------------- +-- Portable container encoding / decoding +-- --------------------------------------------------------------------------- + +data SectionEntry = SectionEntry + { seType :: Word32 + , seVersion :: Word16 + , seFlags :: Word16 + , seCompression :: Word16 + , seDigestAlgorithm :: Word16 + , seOffset :: Word64 + , seLength :: Word64 + , seDigest :: ByteString + } deriving (Show, Eq) + +encodeHeader :: Word16 -> Word16 -> Word32 -> Word64 -> Word64 -> ByteString +encodeHeader major minor sectionCount flags dirOffset = + bundleMagic + <> encode16 major + <> encode16 minor + <> encode32 sectionCount + <> encode64 flags + <> encode64 dirOffset + +encodeSectionEntry :: Word32 -> Word16 -> Word16 -> Word16 -> Word64 -> Word64 -> ByteString -> ByteString +encodeSectionEntry sectionType sectionVersion sectionFlags compression offset lengthBytes sectionBytes = + encode32 sectionType + <> encode16 sectionVersion + <> encode16 sectionFlags + <> encode16 compression + <> encode16 digestSha256 + <> encode64 offset + <> encode64 lengthBytes + <> sha256 sectionBytes + +decodePortableBundle :: ByteString -> Either String Bundle +decodePortableBundle bs = do + (major, minor, sectionCount, _flags, dirOffset) <- decodePortableHeader bs + when (major /= bundleMajorVersion) $ + Left $ "unsupported bundle major version: " ++ show major + let dirStart = fromIntegral dirOffset + dirBytes = fromIntegral sectionCount * sectionEntryLength + when (BS.length bs < dirStart + dirBytes) $ + Left "bundle truncated in section directory" + entries <- decodeSectionEntries sectionCount (BS.take dirBytes $ BS.drop dirStart bs) + traverse_ rejectUnknownCritical entries + manifestEntry <- requireSection sectionManifest entries + nodesEntry <- requireSection sectionNodes entries + manifestBytes <- readAndVerifySection bs manifestEntry + nodesBytes <- readAndVerifySection bs nodesEntry + manifest <- case eitherDecodeStrict' manifestBytes of + Left err -> Left $ "invalid manifest JSON: " ++ err + Right m -> Right m + nodes <- decodeNodeSection nodesBytes + let roots = map rootHash (manifestRoots manifest) + return Bundle + { bundleVersion = major * 1000 + minor + , bundleRoots = roots + , bundleNodes = nodes + , bundleManifest = manifest + , bundleManifestBytes = manifestBytes + } + +rejectUnknownCritical :: SectionEntry -> Either String () +rejectUnknownCritical entry = + let known = seType entry `elem` [sectionManifest, sectionNodes] + critical = seFlags entry .&. flagCritical /= 0 + in when (critical && not known) $ + Left $ "unknown critical section type: " ++ show (seType entry) + +requireSection :: Word32 -> [SectionEntry] -> Either String SectionEntry +requireSection sectionType entries = + case filter ((== sectionType) . seType) entries of + [entry] -> Right entry + [] -> Left $ "missing required section type: " ++ show sectionType + _ -> Left $ "duplicate section type: " ++ show sectionType + +readAndVerifySection :: ByteString -> SectionEntry -> Either String ByteString +readAndVerifySection bs entry = do + when (seCompression entry /= compressionNone) $ + Left $ "unsupported compression codec in section " ++ show (seType entry) + when (seDigestAlgorithm entry /= digestSha256) $ + Left $ "unsupported digest algorithm in section " ++ show (seType entry) + let offset = fromIntegral (seOffset entry) + len = fromIntegral (seLength entry) + when (offset < 0 || len < 0 || BS.length bs < offset + len) $ + Left $ "section extends beyond bundle end: " ++ show (seType entry) + let sectionBytes = BS.take len $ BS.drop offset bs + when (sha256 sectionBytes /= seDigest entry) $ + Left $ "section digest mismatch: " ++ show (seType entry) + Right sectionBytes + +decodePortableHeader :: ByteString -> Either String (Word16, Word16, Word32, Word64, Word64) +decodePortableHeader bs + | BS.length bs < headerLength = Left "bundle too short for header" + | BS.take 8 bs /= bundleMagic = Left "invalid portable bundle magic" + | otherwise = do + (major, r1) <- decode16be "major_version" (BS.drop 8 bs) + (minor, r2) <- decode16be "minor_version" r1 + (sectionCount, r3) <- decode32be "section_count" r2 + (flags, r4) <- decode64be "flags" r3 + (dirOffset, _) <- decode64be "directory_offset" r4 + Right (major, minor, sectionCount, flags, dirOffset) + +decodeSectionEntries :: Word32 -> ByteString -> Either String [SectionEntry] +decodeSectionEntries count bytes = reverse <$> go count bytes [] + where + go 0 _ acc = Right acc + go n bs acc = do + when (BS.length bs < sectionEntryLength) $ + Left "section directory truncated" + (sectionType, r1) <- decode32be "section_type" bs + (sectionVersion, r2) <- decode16be "section_version" r1 + (sectionFlags, r3) <- decode16be "section_flags" r2 + (compression, r4) <- decode16be "compression_codec" r3 + (digAlg, r5) <- decode16be "digest_algorithm" r4 + (offset, r6) <- decode64be "section_offset" r5 + (len, r7) <- decode64be "section_length" r6 + let (dig, rest) = BS.splitAt 32 r7 + when (BS.length dig /= 32) $ Left "section digest truncated" + let entry = SectionEntry sectionType sectionVersion sectionFlags compression digAlg offset len dig + go (n - 1) rest (entry : acc) + +-- --------------------------------------------------------------------------- +-- Manifest construction +-- --------------------------------------------------------------------------- + +defaultManifest :: [(Text, MerkleHash)] -> Int -> BundleManifest +defaultManifest namedRoots nodeCount = BundleManifest + { manifestSchema = "arborix.bundle.manifest.v1" + , manifestBundleType = "tree-calculus-executable-object" + , manifestTree = TreeSpec + { treeCalculus = "tree-calculus.v1" + , treeNodeHash = NodeHashSpec + { nodeHashAlgorithm = "sha256" + , nodeHashDomain = "arborix.merkle.node.v1" + } + , treeNodePayload = "arborix.merkle.payload.v1" + } + , manifestRuntime = RuntimeSpec + { runtimeSemantics = "tree-calculus.v1" + , runtimeEvaluation = "normal-order" + , runtimeAbi = "arborix.abi.tree.v1" + , runtimeCapabilities = [] + } + , manifestClosure = ClosureComplete + , manifestRoots = zipWith mkRoot [0 :: Int ..] (map snd namedRoots) + , manifestExports = map mkExport namedRoots + , manifestImports = [] + , manifestSections = object + [ "nodes" .= object + [ "count" .= nodeCount + , "payload" .= ("arborix.merkle.payload.v1" :: Text) + ] + ] + , manifestMetadata = BundleMetadata + { metadataPackage = Nothing + , metadataVersion = Nothing + , metadataDescription = Nothing + , metadataLicense = Nothing + , metadataCreatedBy = Just "arborix" + } + } + where + mkRoot 0 h = BundleRoot h "default" + mkRoot _ h = BundleRoot h "root" + mkExport (name, h) = BundleExport + { exportName = name + , exportRoot = h + , exportKind = "term" + , exportAbi = "arborix.abi.tree.v1" + , exportInput = Nothing + , exportOutput = Nothing + } + +-- --------------------------------------------------------------------------- +-- Node section encoding / decoding +-- --------------------------------------------------------------------------- + +encodeNodeSection :: Map MerkleHash ByteString -> ByteString +encodeNodeSection nodes = + encode64 (fromIntegral $ Map.size nodes) + <> mconcat (map nodeEntryToBinary $ Map.toAscList nodes) + +-- | Encode a single (hash, canonical-payload) node entry. +nodeEntryToBinary :: (MerkleHash, ByteString) -> ByteString +nodeEntryToBinary (h, payload) = + merkleHashToRaw h + <> encode32 (fromIntegral $ BS.length payload) + <> payload + +decodeNodeSection :: ByteString -> Either String (Map MerkleHash ByteString) +decodeNodeSection bs = do + (nodeCount, rest) <- decode64be "node_count" bs + decodeNodeEntries nodeCount rest + +-- | Decode a sequence of node entries. +decodeNodeEntries :: Word64 -> ByteString -> Either String (Map MerkleHash ByteString) +decodeNodeEntries count bs = go count bs Map.empty + where + go 0 rest acc + | BS.null rest = Right acc + | otherwise = Left "trailing bytes after node section" + go n bytes acc + | BS.length bytes < 36 = + Left "not enough bytes for node entry header (hash + length)" + | otherwise = do + let (hashBytes, rest) = BS.splitAt 32 bytes + (plen, rest') <- decode32be "payload_len" rest + let payloadLen = fromIntegral plen + if BS.length rest' < payloadLen + then Left "payload extends beyond node section end" + else do + let (payload, after) = BS.splitAt payloadLen rest' + h = rawToMerkleHash hashBytes + when (Map.member h acc) $ + Left $ "duplicate node entry: " ++ unpack h + go (n - 1) after (Map.insert h payload acc) + + + +-- --------------------------------------------------------------------------- +-- Bundle verification +-- --------------------------------------------------------------------------- + +verifyBundle :: Bundle -> Either String () +verifyBundle bundle + | bundleVersion bundle < 1 = Left $ "unsupported bundle version: " ++ show (bundleVersion bundle) + | Map.null (bundleNodes bundle) = Left "bundle has no nodes" +verifyBundle bundle = do + verifyManifest (bundleManifest bundle) + let nodeMap = bundleNodes bundle + rootSet = Set.fromList (bundleRoots bundle) + manifestRootSet = Set.fromList (map rootHash $ manifestRoots $ bundleManifest bundle) + exportRoots = map exportRoot $ manifestExports $ bundleManifest bundle + unless (rootSet == manifestRootSet) $ + Left "bundle root list does not match manifest roots" + traverse_ (requirePresent "root hash missing from bundle") (bundleRoots bundle) + traverse_ (requirePresent "export root hash missing from bundle") exportRoots + decoded <- traverse verifyNodePayload (Map.toList nodeMap) + traverse_ (verifyChildrenPresent nodeMap) decoded + verifyCompleteClosure nodeMap (bundleRoots bundle) + where + requirePresent label h = + unless (Map.member h (bundleNodes bundle)) $ + Left $ label ++ ": " ++ unpack h + +verifyManifest :: BundleManifest -> Either String () +verifyManifest manifest = do + when (manifestSchema manifest /= "arborix.bundle.manifest.v1") $ + Left $ "unsupported manifest schema: " ++ unpack (manifestSchema manifest) + when (manifestBundleType manifest /= "tree-calculus-executable-object") $ + Left $ "unsupported bundle type: " ++ unpack (manifestBundleType manifest) + let treeSpec = manifestTree manifest + hashSpec = treeNodeHash treeSpec + runtimeSpec = manifestRuntime manifest + when (treeCalculus treeSpec /= "tree-calculus.v1") $ + Left $ "unsupported calculus: " ++ unpack (treeCalculus treeSpec) + when (nodeHashAlgorithm hashSpec /= "sha256") $ + Left $ "unsupported node hash algorithm: " ++ unpack (nodeHashAlgorithm hashSpec) + when (nodeHashDomain hashSpec /= "arborix.merkle.node.v1") $ + Left $ "unsupported node hash domain: " ++ unpack (nodeHashDomain hashSpec) + when (treeNodePayload treeSpec /= "arborix.merkle.payload.v1") $ + Left $ "unsupported node payload: " ++ unpack (treeNodePayload treeSpec) + when (runtimeSemantics runtimeSpec /= "tree-calculus.v1") $ + Left $ "unsupported runtime semantics: " ++ unpack (runtimeSemantics runtimeSpec) + when (runtimeAbi runtimeSpec /= "arborix.abi.tree.v1") $ + Left $ "unsupported runtime ABI: " ++ unpack (runtimeAbi runtimeSpec) + unless (null $ runtimeCapabilities runtimeSpec) $ + Left "host/runtime capabilities are not supported by bundle v1" + when (manifestClosure manifest /= ClosureComplete) $ + Left "bundle v1 imports require closure = complete" + unless (null $ manifestImports manifest) $ + Left "bundle v1 imports require an empty imports list" + when (null $ manifestRoots manifest) $ + Left "manifest has no roots" + when (null $ manifestExports manifest) $ + Left "manifest has no exports" + traverse_ verifyExport (manifestExports manifest) + where + verifyExport exported = do + when (T.null $ exportName exported) $ + Left "manifest export has empty name" + when (T.null $ exportRoot exported) $ + Left "manifest export has empty root" + +verifyNodePayload :: (MerkleHash, ByteString) -> Either String (MerkleHash, Node) +verifyNodePayload (h, payload) = do + node <- safeDeserializeNode payload + let actual = nodeHash node + unless (actual == h) $ + Left $ "node hash mismatch for " ++ unpack h ++ "; payload hashes to " ++ unpack actual + Right (h, node) + +verifyChildrenPresent :: Map MerkleHash ByteString -> (MerkleHash, Node) -> Either String () +verifyChildrenPresent nodeMap (h, node) = + case node of + NLeaf -> Right () + NStem child -> requireChild h child + NFork left right -> requireChild h left >> requireChild h right + where + requireChild parent child = + unless (Map.member child nodeMap) $ + Left $ "missing child node referenced by " ++ unpack parent ++ ": " ++ unpack child + +verifyCompleteClosure :: Map MerkleHash ByteString -> [MerkleHash] -> Either String () +verifyCompleteClosure nodeMap roots = do + _ <- foldM visit Set.empty roots + Right () + where + visit seen h + | Set.member h seen = Right seen + | otherwise = do + payload <- case Map.lookup h nodeMap of + Nothing -> Left $ "closure missing node: " ++ unpack h + Just p -> Right p + node <- safeDeserializeNode payload + let seen' = Set.insert h seen + case node of + NLeaf -> Right seen' + NStem child -> visit seen' child + NFork left right -> visit seen' left >>= \seenL -> visit seenL right + +safeDeserializeNode :: ByteString -> Either String Node +safeDeserializeNode payload = + case BS.uncons payload of + Just (0x00, rest) + | BS.null rest -> Right NLeaf + | otherwise -> Left "invalid leaf payload length" + Just (0x01, rest) + | BS.length rest == 32 -> Right $ NStem (rawToMerkleHash rest) + | otherwise -> Left "invalid stem payload length" + Just (0x02, rest) + | BS.length rest == 64 -> + let (left, right) = BS.splitAt 32 rest + in Right $ NFork (rawToMerkleHash left) (rawToMerkleHash right) + | otherwise -> Left "invalid fork payload length" + _ -> Left "invalid merkle node payload" + +-- --------------------------------------------------------------------------- +-- Reachability traversal +-- --------------------------------------------------------------------------- + +collectReachableNodes :: Connection -> MerkleHash -> IO [(MerkleHash, ByteString)] +collectReachableNodes conn root = do + let go seen current = do + case Map.lookup current seen of + Just _ -> return seen + Nothing -> do + maybeNode <- getNodeMerkle conn current + case maybeNode of + Nothing -> error $ "exportBundle: missing Merkle node: " ++ unpack current + Just node -> do + let payload = serializeNode node + seen' = Map.insert current payload seen + case node of + NLeaf -> return seen' + NStem childHash -> go seen' childHash + NFork lHash rHash -> go seen' lHash >>= \seenL -> go seenL rHash + seen <- go Map.empty root + return $ Map.toAscList seen + +-- --------------------------------------------------------------------------- +-- High-level export / import +-- --------------------------------------------------------------------------- + +exportBundle :: Connection -> [MerkleHash] -> IO ByteString +exportBundle conn hashes = exportNamedBundle conn (zip (defaultExportNames $ length hashes) hashes) + +exportNamedBundle :: Connection -> [(Text, MerkleHash)] -> IO ByteString +exportNamedBundle conn namedHashes = do + let hashes = map snd namedHashes + entries <- concat <$> mapM (collectReachableNodes conn) hashes + let nodeMap = Map.fromList entries + manifest = defaultManifest namedHashes (Map.size nodeMap) + manifestBytes = BL.toStrict (encode manifest) + bundle = Bundle + { bundleVersion = bundleMajorVersion * 1000 + bundleMinorVersion + , bundleRoots = hashes + , bundleNodes = nodeMap + , bundleManifest = manifest + , bundleManifestBytes = manifestBytes + } + return $ encodeBundle bundle + +importBundle :: Connection -> ByteString -> IO [MerkleHash] +importBundle conn bs = case decodeBundle bs of + Left err -> error $ "Wire.importBundle: " ++ err + Right bundle -> case verifyBundle bundle of + Left err -> error $ "Wire.importBundle verify: " ++ err + Right () -> do + traverse_ (\payload -> do + node <- deserializeForImport payload + putMerkleNode conn node + ) + (Map.elems $ bundleNodes bundle) + registerBundleExports conn bundle + return $ bundleRoots bundle + +registerBundleExports :: Connection -> Bundle -> IO () +registerBundleExports conn bundle = + traverse_ registerExport (manifestExports $ bundleManifest bundle) + where + registerExport exported = do + maybeTree <- loadTree conn (exportRoot exported) + case maybeTree of + Nothing -> error $ "Wire.importBundle: export root missing after node import: " ++ unpack (exportRoot exported) + Just tree -> do + _ <- storeTerm conn [unpack $ exportName exported] tree + return () + +-- --------------------------------------------------------------------------- +-- Primitive binary helpers +-- --------------------------------------------------------------------------- + +encode16 :: Word16 -> ByteString +encode16 w = BS.pack + [ fromIntegral (shiftR w 8) + , fromIntegral w + ] + +encode32 :: Word32 -> ByteString +encode32 w = BS.pack + [ fromIntegral (shiftR w 24) + , fromIntegral (shiftR w 16) + , fromIntegral (shiftR w 8) + , fromIntegral w + ] + +encode64 :: Word64 -> ByteString +encode64 w = BS.pack + [ fromIntegral (shiftR w 56) + , fromIntegral (shiftR w 48) + , fromIntegral (shiftR w 40) + , fromIntegral (shiftR w 32) + , fromIntegral (shiftR w 24) + , fromIntegral (shiftR w 16) + , fromIntegral (shiftR w 8) + , fromIntegral w + ] + +decode16be :: String -> ByteString -> Either String (Word16, ByteString) +decode16be label bs + | BS.length bs < 2 = Left (label ++ ": not enough bytes for u16") + | otherwise = + let b0 = fromIntegral (BS.index bs 0) :: Word16 + b1 = fromIntegral (BS.index bs 1) :: Word16 + in Right ((b0 `shiftL` 8) .|. b1, BS.drop 2 bs) + +-- | Decode a big-endian u32 from the head of a ByteString. +decode32be :: String -> ByteString -> Either String (Word32, ByteString) +decode32be label bs + | BS.length bs < 4 = Left (label ++ ": not enough bytes for u32") + | otherwise = + let b0 = fromIntegral (BS.index bs 0) :: Word32 + b1 = fromIntegral (BS.index bs 1) :: Word32 + b2 = fromIntegral (BS.index bs 2) :: Word32 + b3 = fromIntegral (BS.index bs 3) :: Word32 + val = (b0 `shiftL` 24) .|. (b1 `shiftL` 16) + .|. (b2 `shiftL` 8) .|. b3 + in Right (val, BS.drop 4 bs) + +decode64be :: String -> ByteString -> Either String (Word64, ByteString) +decode64be label bs + | BS.length bs < 8 = Left (label ++ ": not enough bytes for u64") + | otherwise = + let byte i = fromIntegral (BS.index bs i) :: Word64 + val = (byte 0 `shiftL` 56) .|. (byte 1 `shiftL` 48) + .|. (byte 2 `shiftL` 40) .|. (byte 3 `shiftL` 32) + .|. (byte 4 `shiftL` 24) .|. (byte 5 `shiftL` 16) + .|. (byte 6 `shiftL` 8) .|. byte 7 + in Right (val, BS.drop 8 bs) + +-- --------------------------------------------------------------------------- +-- Hash conversion +-- --------------------------------------------------------------------------- + +-- | Convert a hex MerkleHash to its raw 32-byte representation. +merkleHashToRaw :: MerkleHash -> ByteString +merkleHashToRaw h = + case Base16.decode (encodeUtf8 h) of + Left _ -> error $ "Wire.merkleHashToRaw: invalid hex: " ++ show h + Right bs + | BS.length bs == 32 -> bs + | otherwise -> error $ "Wire.merkleHashToRaw: expected 32 bytes: " ++ show h + +-- | Convert raw 32 bytes back to a hex MerkleHash. +rawToMerkleHash :: ByteString -> MerkleHash +rawToMerkleHash bs = decodeUtf8 (Base16.encode bs) + +sha256 :: ByteString -> ByteString +sha256 bytes = convert ((hash bytes) :: Digest SHA256) + +defaultExportNames :: Int -> [Text] +defaultExportNames n = + case n of + 0 -> [] + 1 -> ["root"] + _ -> ["root" <> T.pack (show i) | i <- [0 :: Int .. n - 1]] + +deserializeForImport :: ByteString -> IO Node +deserializeForImport payload = do + result <- try (evaluate $ deserializeNode payload) :: IO (Either SomeException Node) + case result of + Left err -> error $ "Wire.importBundle: invalid merkle node payload: " ++ show err + Right node -> return node diff --git a/test/Spec.hs b/test/Spec.hs @@ -6,16 +6,24 @@ import Lexer import Parser import REPL import Research +import Wire +import ContentStore import Control.Exception (evaluate, try, SomeException) import Control.Monad.IO.Class (liftIO) +import Data.Bits (xor) import Data.List (isInfixOf) +import Data.Text (Text, unpack) +import Data.Word (Word8) import Test.Tasty import Test.Tasty.HUnit import Text.Megaparsec (runParser) +import Data.ByteString (ByteString) +import qualified Data.ByteString as BS import qualified Data.Map as Map import qualified Data.Set as Set +import Database.SQLite.Simple (close, Connection) main :: IO () main = defaultMain tests @@ -36,6 +44,7 @@ tests = testGroup "Tricu Tests" , decoding , elimLambdaSingle , stressElimLambda + , wireTests ] lexer :: TestTree @@ -640,3 +649,277 @@ stressElimLambda = testCase "stress elimLambda on wide list under deep curried l let before = result (evalTricu Map.empty prog) after = result (evalTricu Map.empty out) after @?= before + +-- -------------------------------------------------------------------------- +-- Wire module tests +-- -------------------------------------------------------------------------- + +-- | Helper: create a temporary file-backed DB, store a term, return the +-- connection and the term (so callers can compare after round-trip). +storeTermInTempDB :: String -> IO (Connection, Text, T) +storeTermInTempDB src = do + conn <- newContentStore + let asts = parseTricu src + finalEnv = evalTricu Map.empty asts + term = result finalEnv + -- storeMerkleNodes returns MerkleHash as Text; storeTerm expects [String] + _ <- storeTerm conn [] term + return (conn, hashTerm term, term) + +-- | Load a term from a DB by its stored hash Text. +loadTermByHash :: Connection -> Text -> IO T +loadTermByHash conn h = do + maybeTerm <- loadTree conn h + case maybeTerm of + Just t -> return t + Nothing -> errorWithoutStackTrace $ "hash not found in store: " ++ Data.Text.unpack h + +-- | Flip one byte in a ByteString at the given index. +corruptByte :: ByteString -> Int -> ByteString +corruptByte bs i = BS.take i bs <> BS.pack [(BS.index bs i `xor` 0x01)] <> BS.drop (i + 1) bs + +wireTests :: TestTree +wireTests = testGroup "Wire Tests" + [ testCase "Portable bundle: header and manifest declare Tree Calculus object format" $ do + (srcConn, termHash, _) <- storeTermInTempDB $ unlines + [ "id = a : a" + , "main = id t" + ] + wireData <- exportBundle srcConn [termHash] + BS.take 8 wireData @?= BS.pack [0x41, 0x52, 0x42, 0x4f, 0x52, 0x49, 0x58, 0x00] + case decodeBundle wireData of + Left err -> assertFailure $ "decodeBundle failed: " ++ err + Right bundle -> do + let manifest = bundleManifest bundle + tree = manifestTree manifest + hashSpec = treeNodeHash tree + runtime = manifestRuntime manifest + manifestSchema manifest @?= "arborix.bundle.manifest.v1" + manifestBundleType manifest @?= "tree-calculus-executable-object" + manifestClosure manifest @?= ClosureComplete + treeCalculus tree @?= "tree-calculus.v1" + treeNodePayload tree @?= "arborix.merkle.payload.v1" + nodeHashAlgorithm hashSpec @?= "sha256" + nodeHashDomain hashSpec @?= "arborix.merkle.node.v1" + runtimeSemantics runtime @?= "tree-calculus.v1" + runtimeAbi runtime @?= "arborix.abi.tree.v1" + runtimeCapabilities runtime @?= [] + bundleRoots bundle @?= [termHash] + map exportRoot (manifestExports manifest) @?= [termHash] + close srcConn + + , testCase "Portable bundle: named exports are manifest aliases for Merkle roots" $ do + (srcConn, termHash, _) <- storeTermInTempDB $ unlines + [ "validateEmail = a : a" + , "main = validateEmail t" + ] + wireData <- exportNamedBundle srcConn [("validateEmail", termHash)] + case decodeBundle wireData of + Left err -> assertFailure $ "decodeBundle failed: " ++ err + Right bundle -> do + bundleRoots bundle @?= [termHash] + case manifestExports (bundleManifest bundle) of + [exported] -> do + exportName exported @?= "validateEmail" + exportRoot exported @?= termHash + exportKind exported @?= "term" + exportAbi exported @?= "arborix.abi.tree.v1" + exports -> assertFailure $ "Expected one export, got: " ++ show exports + close srcConn + + , testCase "Portable bundle: renaming an export changes bundle bytes but not tree identity" $ do + (srcConn, termHash, _) <- storeTermInTempDB $ unlines + [ "f = a : a" + , "main = f t" + ] + mainBundleData <- exportNamedBundle srcConn [("main", termHash)] + renamedBundleData <- exportNamedBundle srcConn [("validate", termHash)] + assertBool "Renaming an export should change the manifest/bundle bytes" + (mainBundleData /= renamedBundleData) + case (decodeBundle mainBundleData, decodeBundle renamedBundleData) of + (Right mainBundle, Right renamedBundle) -> do + bundleRoots mainBundle @?= [termHash] + bundleRoots renamedBundle @?= [termHash] + map exportRoot (manifestExports $ bundleManifest mainBundle) + @?= map exportRoot (manifestExports $ bundleManifest renamedBundle) + map exportName (manifestExports $ bundleManifest mainBundle) @?= ["main"] + map exportName (manifestExports $ bundleManifest renamedBundle) @?= ["validate"] + (Left err, _) -> assertFailure $ "decodeBundle main failed: " ++ err + (_, Left err) -> assertFailure $ "decodeBundle renamed failed: " ++ err + close srcConn + + , testCase "Portable bundle: exact byte export is deterministic" $ do + (srcConn, termHash, _) <- storeTermInTempDB $ unlines + [ "x = t t" + , "main = t x" + ] + first <- exportBundle srcConn [termHash] + second <- exportBundle srcConn [termHash] + first @?= second + close srcConn + + , testCase "Portable bundle: raw section tampering is rejected by digest verification" $ do + (srcConn, termHash, _) <- storeTermInTempDB $ unlines + [ "x = t" + , "main = t x" + ] + wireData <- exportBundle srcConn [termHash] + let tampered = corruptByte wireData (BS.length wireData - 1) + case decodeBundle tampered of + Left err -> assertBool ("Expected section digest mismatch, got: " ++ err) + ("digest mismatch" `isInfixOf` err) + Right _ -> assertFailure "Expected decodeBundle to reject tampered section bytes" + close srcConn + + , testCase "Portable bundle: unsupported manifest semantics are rejected" $ do + (srcConn, termHash, _) <- storeTermInTempDB $ unlines + [ "x = t" + , "main = t x" + ] + wireData <- exportBundle srcConn [termHash] + case decodeBundle wireData of + Left err -> assertFailure $ "decodeBundle failed: " ++ err + Right bundle -> do + let manifest = bundleManifest bundle + partialBundle = bundle + { bundleManifest = manifest { manifestClosure = ClosurePartial } + , bundleManifestBytes = BS.empty + } + capabilityBundle = bundle + { bundleManifest = manifest + { manifestRuntime = (manifestRuntime manifest) + { runtimeCapabilities = ["host.io"] + } + } + , bundleManifestBytes = BS.empty + } + wrongHashBundle = bundle + { bundleManifest = manifest + { manifestTree = (manifestTree manifest) + { treeNodeHash = (treeNodeHash $ manifestTree manifest) + { nodeHashAlgorithm = "blake3" } + } + } + , bundleManifestBytes = BS.empty + } + case verifyBundle partialBundle of + Left err -> assertBool ("Expected closure error, got: " ++ err) ("closure = complete" `isInfixOf` err) + Right () -> assertFailure "Expected partial closure to be rejected" + case verifyBundle capabilityBundle of + Left err -> assertBool ("Expected capability error, got: " ++ err) ("capabilities" `isInfixOf` err) + Right () -> assertFailure "Expected runtime capabilities to be rejected" + case verifyBundle wrongHashBundle of + Left err -> assertBool ("Expected hash algorithm error, got: " ++ err) ("node hash algorithm" `isInfixOf` err) + Right () -> assertFailure "Expected unsupported node hash algorithm to be rejected" + close srcConn + + , testCase "Portable bundle: import registers manifest export names in fresh content store" $ do + (srcConn, termHash, originalTerm) <- storeTermInTempDB $ unlines + [ "validateEmail = a : a" + , "main = validateEmail t" + ] + wireData <- exportNamedBundle srcConn [("validateEmail", termHash)] + dstConn <- newContentStore + _ <- importBundle dstConn wireData + loadedByHash <- loadTermByHash dstConn termHash + loadedByName <- loadTerm dstConn "validateEmail" + loadedByHash @?= originalTerm + loadedByName @?= Just originalTerm + close srcConn + close dstConn + + , testCase "Round-trip: store, export, import, load" $ do + -- Store a term + (srcConn, termHash, originalTerm) <- storeTermInTempDB $ unlines + [ "x = t" + , "y = t x" + , "z = t y" + , "main = z" + ] + -- Export by root hash + wireData <- exportBundle srcConn [termHash] + -- Import into a fresh DB + dstConn <- newContentStore + _ <- importBundle dstConn wireData + -- Load the term back and compare + loadedTerm <- loadTermByHash dstConn termHash + loadedTerm @?= originalTerm + -- Cleanup + close srcConn + close dstConn + + , testCase "Round-trip: evaluate from original, export, import, load root" $ do + (srcConn, termHash, originalTerm) <- storeTermInTempDB $ unlines + [ "add = a b : t (t a) b" + , "val = add (t t) (t)" + , "main = val" + ] + -- Export + wireData <- exportBundle srcConn [termHash] + -- Import into fresh DB + dstConn <- newContentStore + _ <- importBundle dstConn wireData + -- Load the root term by hash and compare + loadedTerm <- loadTermByHash dstConn termHash + loadedTerm @?= originalTerm + close srcConn + close dstConn + + , testCase "Negative: corrupt payload byte causes import to fail" $ do + (srcConn, termHash, _) <- storeTermInTempDB $ unlines + [ "x = t" + , "y = t x" + , "z = t y" + , "main = z" + ] + wireData <- exportBundle srcConn [termHash] + -- Decode, mutate one node's payload byte, re-encode + case decodeBundle wireData of + Left err -> assertFailure $ "decodeBundle failed: " ++ err + Right bundle -> do + let (h, payload) = + head + [ (h', p) + | (h', p) <- Map.toList (bundleNodes bundle) + , BS.length p > 0 + ] + payload' = BS.pack [(BS.head payload `xor` 0x01)] <> BS.tail payload + bundle' = bundle { bundleNodes = Map.insert h payload' (bundleNodes bundle) } + wireData' = encodeBundle bundle' + dstConn <- newContentStore + result <- try (importBundle dstConn wireData') :: IO (Either SomeException [MerkleHash]) + case result of + Left e -> + assertBool ("Expected hash mismatch or invalid payload, got: " ++ show e) + $ "mismatch" `isInfixOf` show e || "invalid" `isInfixOf` show e + Right _ -> + assertFailure "Expected import to fail on corrupted payload" + close dstConn + close srcConn + + , testCase "Negative: missing child node causes import to fail" $ do + (srcConn, termHash, _) <- storeTermInTempDB $ unlines + [ "x = t" + , "y = t x" + , "z = t y" + , "main = z" + ] + wireData <- exportBundle srcConn [termHash] + -- Decode, remove a node, re-encode + case decodeBundle wireData of + Left err -> assertFailure $ "decodeBundle failed: " ++ err + Right bundle -> do + let nodeList = Map.toList (bundleNodes bundle) + trimmed = Map.fromList (tail nodeList) + newBundle = bundle { bundleNodes = trimmed } + newWire = encodeBundle newBundle + dstConn <- newContentStore + result <- try (importBundle dstConn newWire) :: IO (Either SomeException [MerkleHash]) + case result of + Left e -> + assertBool ("Expected verify error, got: " ++ show e) True + Right _ -> + assertFailure "Expected import to fail on missing child node" + close dstConn + close srcConn + ] diff --git a/test/fixtures/equalQ.tri.bundle b/test/fixtures/equalQ.tri.bundle Binary files differ. diff --git a/test/fixtures/false.tri.bundle b/test/fixtures/false.tri.bundle Binary files differ. diff --git a/test/fixtures/id.tri.bundle b/test/fixtures/id.tri.bundle Binary files differ. diff --git a/test/fixtures/notQ.tri b/test/fixtures/notQ.tri @@ -0,0 +1,2 @@ +\!import "base.tri" _ +main = not? diff --git a/test/fixtures/notQ.tri.bundle b/test/fixtures/notQ.tri.bundle Binary files differ. diff --git a/test/fixtures/true.tri.bundle b/test/fixtures/true.tri.bundle Binary files differ. diff --git a/tricu.cabal b/tricu.cabal @@ -51,15 +51,20 @@ executable tricu , filepath , fsnotify , haskeline + , http-types , megaparsec , memory , mtl + , servant , sqlite-simple + , stm , tasty , tasty-hunit , text , time , transformers + , wai + , warp , zlib other-modules: ContentStore @@ -70,6 +75,8 @@ executable tricu Paths_tricu REPL Research + Server + Wire default-language: Haskell2010 test-suite tricu-tests @@ -98,15 +105,20 @@ test-suite tricu-tests , filepath , fsnotify , haskeline + , http-types , megaparsec , memory , mtl + , servant , sqlite-simple + , stm , tasty , tasty-hunit , text , time , transformers + , warp + , wai , zlib default-language: Haskell2010 other-modules: @@ -118,3 +130,5 @@ test-suite tricu-tests Paths_tricu REPL Research + Server + Wire