diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..7bff9e56 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Editor configuration for this repository. The Julia sources indent with four +# spaces, the frontend with two, YAML with two, and every text file ends with +# a newline and no trailing whitespace. + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.jl] +indent_style = space +indent_size = 4 + +[*.{jl}] +max_line_length = off + +[{*.ts,*.tsx,*.js,*.jsx,*.json,*.css,*.html}] +indent_style = space +indent_size = 2 + +[{*.yml,*.yaml,*.kyaml}] +indent_style = space +indent_size = 2 + +[*.md] +indent_style = space +indent_size = 2 +# Markdown's hard line breaks are two trailing spaces; keep them. +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab + +[Justfile] +indent_style = space +indent_size = 4 diff --git a/.github/SECURITY.md b/.github/SECURITY.md index c1c97236..f97f2309 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -60,7 +60,7 @@ in its `attestation.yml`. it enabled; the *Report a vulnerability* button on the Security tab reaches the maintainers privately. Use it. -**By email:** contact **jjewell23@rvc.ac.uk** (Jonathan Jewell, the project +**By email:** contact **developer@joshuajewell.dev** (Jonathan Jewell, the project owner) with `[security]` in the subject line. Include, where they apply: diff --git a/.gitignore b/.gitignore index 42d6c9c7..421b96ab 100644 --- a/.gitignore +++ b/.gitignore @@ -323,6 +323,7 @@ archive/ !.github !.github/** !.Rprofile +!.editorconfig # Brainstorming visual-companion scratch (mock-ups, server state) .superpowers/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..9f52ed7f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ + + +# Changelog + +Release notes live under `docs/release-notes/`, one file per release: + +- [v0.1.0](docs/release-notes/v0.1.0.md) + +This file is the root index the RSR scheme expects; the notes themselves are +kept where they have always been, and a new release adds a line here in the +same commit that adds its file there. diff --git a/Justfile b/Justfile new file mode 100644 index 00000000..b1cbe4da --- /dev/null +++ b/Justfile @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# MetaManifold's task entry (RSR scheme: a thin Justfile at the root; the +# heavy work lives in the commands it names). Every recipe delegates to the +# repository's own documented entry points -- install.sh, start.sh, the test +# runner -- so there is exactly one place where each of those is defined and +# this file never re-implements them. + +# The default recipe lists what exists; `just --list` does the same. +default: + @just --list + +# The documented installer: Julia (pinned), R, the external tools, the +# frontend build. Extra args pass through to install.sh (--update, +# --modify, --sysimage). +install *ARGS: + bash install.sh {{ARGS}} + +# The documented server entry point. Serves the UI on port 8080. +serve: + bash start.sh + +# The full unit suite. Name files to run a subset: +# just test test_install_pins.jl test_server_security.jl +test *FILES: + julia --project=. test/runtests.jl {{FILES}} + +# The pin suite alone: toolchain pins, lockfile agreement, workflow shape, +# and the root-shape allowlist. +pins: + julia --project=. test/runtests.jl test_install_pins.jl + +# The Agda proof tree's audit (no postulates, no holes, everything reached +# from All.agda). The type-check itself runs in CI's Agda proofs job. +proofs: + bash proofs/tests/axiom-audit.sh + +# The frontend's own checks. +frontend-test: + cd frontend && bun run test + +frontend-typecheck: + cd frontend && bun run typecheck + +frontend-build: + cd frontend && bun run build + +# The git-signing agent's state, as the launcher sees it. +agent-status: + ./launcher.sh --agent status diff --git a/launcher.sh b/launcher.sh new file mode 100755 index 00000000..d37ba967 --- /dev/null +++ b/launcher.sh @@ -0,0 +1,295 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# @launcher-deed begin +# ;; SPDX-License-Identifier: MPL-2.0 +# (praxis-deed +# :schema-version "1.0.0" +# :canonical-name "metamanifold-webui-launcher" +# (artefact :type "launcher" :version "0.1.0" +# :generator "hand-adapted from launcher-standard 0.6.0's archetype") +# (app :name "MetaManifold" :display "MetaManifold" +# :url "https://github.com/hyperpolymath/MetaManifold-WebUI" +# :archetype "application") +# (compliance :standard-version "0.6.0" +# :standards ("launcher-standard.adoc")) +# (modes :accepted ("--setup" "--start" "--doctor" "--status" "--agent" "--help" "--version")) +# (platforms :supported ("linux" "macos")) +# (lifecycle-phases :covered ("provision" "diagnose" "run") +# :deferred ("heal"))) +# @launcher-deed end +# +# The MetaManifold launcher, in the launcher-standard's mode vocabulary but +# self-contained: this is an application repository, not an RSR-minted one, +# so the modes delegate directly to the repository's own entry points +# (install.sh, start.sh) rather than through provision-set machinery. +# +# The one thing that is this launcher's own is --agent: the git-signing +# agent's persistence is the operator's choice, and every mode of it is +# reversible. The signing key is ~/.ssh/git-signing2; the socket is +# /tmp/git-signing-agent.sock. +# +# --agent systemd a systemd user service owns the agent, and the shell +# profile exports the socket (a service cannot export +# into login shells). Survives reboots; one instance. +# --agent profile the shell profile starts the agent if the socket is +# dead and exports it. No systemd; works over plain ssh. +# --agent both the service plus the spawn-if-dead fallback line. +# --agent none removes whatever of the above was installed. +# --agent status reports socket, key, and which mechanisms are present. +# +# Each mechanism is written between marked blocks in the profile and a unit +# file, idempotently, with a dated backup of anything edited. + +set -uo pipefail + +REPO_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" +SOCKET="/tmp/git-signing-agent.sock" +KEY="${HOME}/.ssh/git-signing2" +UNIT_NAME="git-signing-agent" +UNIT_FILE="${HOME}/.config/systemd/user/${UNIT_NAME}.service" +PROFILE="${HOME}/.profile" +BLOCK_BEGIN="# >>> metamanifold git-signing agent >>>" +BLOCK_END="# <<< metamanifold git-signing agent <<<" + +# Print all arguments as one launcher-prefixed message to standard output. +say() { printf 'launcher: %s\n' "$*"; } + +# Print all arguments as one launcher-prefixed message to standard error. +warn() { printf 'launcher: %s\n' "$*" >&2; } + +# Report all arguments through warn and terminate the launcher with status 1. +die() { warn "$*"; exit 1; } + +# Return 0 only when SOCKET is a Unix socket and ssh-add can list identities. +# An agent with no loaded identities also returns non-zero. +agent_alive() { + [ -S "$SOCKET" ] && SSH_AUTH_SOCK="$SOCKET" ssh-add -l >/dev/null 2>&1 +} + +# Remove marked agent blocks from PROFILE, following symlinks and preserving +# file permissions. A missing profile succeeds. Return 1 for nested or +# unterminated blocks, or file-operation failures; no backup is made here. +remove_profile_block() { + [ -f "$PROFILE" ] || return 0 + local target tmp mode + target="$(readlink -f "$PROFILE")" || return 1 + tmp="$(mktemp "$(dirname "$target")/.profile.tmp.XXXXXX")" || return 1 + if ! awk -v b="$BLOCK_BEGIN" -v e="$BLOCK_END" ' + $0 == b { + if (skip) exit 1 + skip = 1 + next + } + $0 == e { + if (skip) { skip = 0; next } + print + next + } + !skip { print } + END { if (skip) exit 1 } + ' "$target" > "$tmp"; then + rm -f "$tmp" + return 1 + fi + mode="$(stat -c '%a' "$target" 2>/dev/null || stat -f '%Lp' "$target")" || { + rm -f "$tmp" + return 1 + } + chmod "$mode" "$tmp" || { + rm -f "$tmp" + return 1 + } + mv "$tmp" "$target" || { + rm -f "$tmp" + return 1 + } +} + +# Back up PROFILE and replace its marked agent block, creating the file if absent. +# "export" sets the socket for future shells; "spawn" also tries to start an +# agent when ssh-add cannot list identities. Neither mode loads the signing key. +# Exit 1 if profile creation, backup or block removal fails; append failures +# return a non-zero status and do not restore the backup. +write_profile_block() { # $1 = "export" | "spawn" + [ -f "$PROFILE" ] || touch "$PROFILE" || die "could not create $PROFILE" + local backup + backup="$(mktemp "$PROFILE.launcher-backup.$(date +%Y%m%d%H%M%S).XXXXXX")" || + die "could not create backup of $PROFILE" + cp -p "$PROFILE" "$backup" || { + rm -f "$backup" + die "backup of $PROFILE failed" + } + remove_profile_block || die "could not update $PROFILE" + { + printf '%s\n' "$BLOCK_BEGIN" + if [ "$1" = "spawn" ]; then + printf 'if [ ! -S "%s" ] || ! SSH_AUTH_SOCK="%s" ssh-add -l >/dev/null 2>&1; then\n' "$SOCKET" "$SOCKET" + printf ' ssh-agent -a "%s" >/dev/null 2>&1\n' "$SOCKET" + printf 'fi\n' + fi + printf 'export SSH_AUTH_SOCK="%s"\n' "$SOCKET" + printf '%s\n' "$BLOCK_END" + } >> "$PROFILE" +} + +# Create or overwrite UNIT_FILE for an ssh-agent on SOCKET, without a backup. +# Return the write status; this does not enable or start the service. +write_unit() { + mkdir -p "$(dirname "$UNIT_FILE")" + cat > "$UNIT_FILE" </dev/null || die "systemctl not found; use --agent profile" + write_unit + systemctl --user daemon-reload + systemctl --user enable --now "$UNIT_NAME" || die "could not enable ${UNIT_NAME} (try: loginctl enable-linger)" + write_profile_block export + say "systemd user service enabled; profile exports SSH_AUTH_SOCK=$SOCKET" + say "note: over plain ssh, or without a user session, also run: loginctl enable-linger \$USER" + ;; + profile) + write_profile_block spawn + say "profile now starts the agent if the socket is dead and exports SSH_AUTH_SOCK=$SOCKET" + ;; + both) + command -v systemctl >/dev/null || die "systemctl not found; use --agent profile" + write_unit + systemctl --user daemon-reload + systemctl --user enable --now "$UNIT_NAME" || die "could not enable ${UNIT_NAME}" + write_profile_block spawn + say "systemd service plus spawn-if-dead profile fallback installed" + ;; + none) + if command -v systemctl >/dev/null 2>&1; then + systemctl --user disable --now "$UNIT_NAME" >/dev/null 2>&1 || + warn "could not disable $UNIT_NAME" + else + warn "systemctl not found; user unit was not disabled" + fi + rm -f "$UNIT_FILE" + if command -v systemctl >/dev/null 2>&1; then + systemctl --user daemon-reload || + warn "could not reload the user manager" + fi + if [ -f "$PROFILE" ]; then + backup="$(mktemp "$PROFILE.launcher-backup.$(date +%Y%m%d%H%M%S).XXXXXX")" || { + warn "could not create backup of $PROFILE; profile block was not removed" + return 1 + } + if ! cp -p "$PROFILE" "$backup"; then + rm -f "$backup" + warn "could not back up $PROFILE; profile block was not removed" + return 1 + fi + fi + if ! remove_profile_block; then + warn "could not remove the profile block" + return 1 + fi + say "removed unit and profile block; any agent already running is left alone" + ;; + status) + agent_alive && say "socket alive: $SOCKET" || say "socket dead or absent: $SOCKET" + [ -f "$KEY" ] && say "key present: $KEY" || say "key MISSING: $KEY" + [ -f "$UNIT_FILE" ] && say "systemd unit: installed" || say "systemd unit: not installed" + grep -q "$BLOCK_BEGIN" "$PROFILE" 2>/dev/null && say "profile block: present" || say "profile block: absent" + ;; + *) + die "usage: launcher.sh --agent systemd|profile|both|none|status" + ;; + esac +} + +# Report toolchain and signing readiness, then exit 1 if julia, bun, R or git +# is missing from PATH, or agent_alive fails; otherwise exit 0. Missing tool +# configuration or a signing-key check failure only produces a warning. +doctor() { + fail=0 + for tool in julia bun R git; do + if command -v "$tool" >/dev/null; then + say "ok: $tool on PATH" + else + warn "MISSING: $tool not on PATH" + fail=1 + fi + done + [ -f "$REPO_DIR/config/tools.yml" ] && say "ok: config/tools.yml exists" \ + || warn "config/tools.yml missing (run: ./launcher.sh --setup)" + if agent_alive; then + say "ok: signing agent alive at $SOCKET" + SSH_AUTH_SOCK="$SOCKET" ssh-add -l 2>/dev/null | grep -q "$(ssh-keygen -lf "${KEY}.pub" 2>/dev/null | awk '{print $2}')" \ + && say "ok: signing key loaded" || warn "signing key not loaded (run: ssh-add $KEY)" + else + warn "signing agent not reachable at $SOCKET (see: ./launcher.sh --agent status)" + fail=1 + fi + exit "$fail" +} + +# Print the launcher header and available modes to standard output. +help() { + sed -n '2,40p' "${BASH_SOURCE[0]}" | grep '^#' | sed 's/^# \{0,1\}//' + cat <<'MODES' +Modes: + --setup install everything (delegates to install.sh; args pass through) + --start serve the web UI (delegates to start.sh) + --doctor check the toolchain and the signing agent + --status whether the server looks reachable on its port + --agent MODE systemd | profile | both | none | status (the signing agent) + --help this text + --version the launcher's version +MODES +} + +# Print the launcher and launcher-standard versions to standard output. +version() { echo "metamanifold-webui-launcher 0.1.0 (launcher-standard 0.6.0, hand-adapted)"; } + +# Dispatch the first argument as a launcher mode. Setup replaces this process +# with install.sh and forwards remaining arguments; start executes start.sh. +# Status checks the configured local port (default 8080) with a two-second +# timeout; HTTP error responses count as reachable and connection failure does +# not set a failure exit status. Missing or unknown modes print help and exit 1. +main() { + case "${1:-}" in + --setup) shift; exec bash "$REPO_DIR/install.sh" "$@" ;; + --start) exec bash "$REPO_DIR/start.sh" ;; + --doctor) doctor ;; + --status) + status_url="http://127.0.0.1:${JULIA_METAMANIFOLD_PORT:-8080}/" + if curl -s -o /dev/null --max-time 2 "$status_url"; then + say "something is answering on $status_url" + else + say "nothing answering on $status_url (start with: ./launcher.sh --start)" + fi + ;; + --agent) shift; agent_mode "${1:-}" ;; + --help|-h) help ;; + --version) version ;; + *) help; exit 1 ;; + esac +} + +main "$@" diff --git a/scripts/kyaml-format.sh b/scripts/kyaml-format.sh new file mode 100755 index 00000000..62163356 --- /dev/null +++ b/scripts/kyaml-format.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# kyaml-format.sh — format arbitrary YAML as KYAML, or check that it already is +# (YAML-POLICY Y-3; KYAML adoption step 3, standards#1022). +# +# The rewriter is `yq -p yaml -o kyaml '.'`, verbatim — the one rewriter the +# step-2 comment-preservation proof (tools/yaml-comment-proof, standards#1021) +# covers. That proof does not generalise to any other rewriter, so this script +# deliberately adds none: no post-processing, no re-indenting, no sed. +# +# Usage: +# kyaml-format.sh [--check] FILE... +# +# (default) rewrite each FILE in place as KYAML when it is not already. +# --check rewrite nothing; exit 1 if any FILE is not already KYAML. +# +# Exit contract: +# 0 = every FILE is KYAML (after the rewrite, in format mode) +# 1 = --check: at least one FILE is not KYAML (each one is named) +# 2 = refused: no FILE given, a FILE is missing or unparseable, a FILE has no +# final newline, or the rewrite would change the parsed data. A refusal +# never rewrites that FILE and never counts as clean. +# +# Why the final-newline refusal: a `|` block scalar that ends at EOF with no +# line break is read differently by go-yaml (yq) and eemeli/yaml — measured on +# k9-contractile.yml, the trailing "\n" of its last run: body exists for one +# parser and not the other. Formatting such a file would silently pick one +# reading, so the script refuses it and names the fix instead. +set -uo pipefail + +MODE=format +if [ "${1:-}" = "--check" ]; then + MODE=check + shift +fi + +if [ "$#" -eq 0 ]; then + echo "kyaml-format: no files given (usage: kyaml-format.sh [--check] FILE...)" >&2 + exit 2 +fi + +command -v yq >/dev/null 2>&1 || { echo "kyaml-format: yq is not on PATH" >&2; exit 2; } + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# canonical_data — prints the file's parsed value as key-sorted JSON; +# fails when the file does not parse. +canonical_data() { + yq -p yaml -o json -I 0 'sort_keys(..)' "$1" +} + +# kyaml_of — writes the KYAML rendering of to ; +# fails when does not parse. +kyaml_of() { + yq -p yaml -o kyaml '.' "$1" > "$2" +} + +# refuse — reports a file this script will not judge. +refuse() { + echo "::error file=$1::kyaml-format: REFUSED $1 — $2" >&2 + refused=$((refused + 1)) +} + +refused=0 drift=0 clean=0 rewritten=0 +i=0 +for f in "$@"; do + i=$((i + 1)) + out="$TMP/$i.kyaml" + if [ ! -f "$f" ]; then + refuse "$f" "no such file"; continue + fi + if [ -s "$f" ] && [ -n "$(tail -c1 "$f")" ]; then + refuse "$f" "no final newline; add one first (a trailing block scalar is ambiguous without it)"; continue + fi + if ! kyaml_of "$f" "$out" 2>"$TMP/err"; then + refuse "$f" "does not parse: $(head -1 "$TMP/err")"; continue + fi + if cmp -s "$f" "$out"; then + clean=$((clean + 1)); continue + fi + # The rewrite is only acceptable when it preserves the data exactly. + if ! before=$(canonical_data "$f") || ! after=$(canonical_data "$out") || [ "$before" != "$after" ]; then + refuse "$f" "the KYAML rendering does not parse to the same data"; continue + fi + if [ "$MODE" = check ]; then + echo "::error file=$f::kyaml-format: $f is not KYAML — run scripts/kyaml-format.sh $f" >&2 + drift=$((drift + 1)) + else + target="$(readlink -f "$f")" || { + refuse "$f" "could not resolve the rewrite target"; continue + } + rewrite_tmp="$(mktemp "$(dirname "$target")/.kyaml-format.XXXXXX")" || { + refuse "$f" "could not create a temporary file beside the target"; continue + } + if ! cp -p "$target" "$rewrite_tmp" || ! cat "$out" > "$rewrite_tmp" || ! mv "$rewrite_tmp" "$target"; then + rm -f "$rewrite_tmp" + refuse "$f" "could not rewrite the file"; continue + fi + echo "kyaml-format: rewrote $f" + rewritten=$((rewritten + 1)) + fi +done + +total=$# +if [ "$refused" -gt 0 ]; then + echo "kyaml-format: $refused of $total file(s) REFUSED — not judged, not clean" >&2 + exit 2 +fi +if [ "$drift" -gt 0 ]; then + echo "kyaml-format: $drift of $total file(s) are not KYAML" >&2 + exit 1 +fi +if [ "$MODE" = check ]; then + echo "kyaml-format: all $total file(s) are KYAML" +else + echo "kyaml-format: $total file(s) KYAML ($rewritten rewritten, $clean already clean)" +fi +exit 0 diff --git a/src/MetaManifold.jl b/src/MetaManifold.jl index 31db2a5c..b5f13a59 100644 --- a/src/MetaManifold.jl +++ b/src/MetaManifold.jl @@ -33,6 +33,9 @@ include("analysis/diversity.jl") include("analysis/analysis.jl") include("analysis/differential.jl") +# Canonical JSON: deterministic bytes for hashing and content-addressing. +include("canonical.jl") + # Web server include("server/server.jl") diff --git a/src/canonical.jl b/src/canonical.jl new file mode 100644 index 00000000..04363037 --- /dev/null +++ b/src/canonical.jl @@ -0,0 +1,203 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# JCS (RFC 8785) canonical JSON: deterministic bytes for hashing, signing and +# content-addressing. The same value always yields the same byte string, so a +# chart spec or an analysis result can be hashed where it is produced and the +# hash compared across machines, Julia versions and refactorings that build +# the same object in a different order. +# +# What this implements, and what it deliberately does not: +# +# * Object keys are sorted by UTF-16 code unit, the rule that makes an +# astral-plane key sort BEFORE a key such as U+FF21, because its high +# surrogate (U+D800..U+DBFF) is a smaller code unit than U+FF21. +# * Float64 values serialize per ECMAScript's Number::toString, the rules +# RFC 8785 builds on: shortest round-trip digits, positional notation for +# 1e-6 <= |x| < 1e21, exponent notation outside it, a signed exponent +# ("1e+30"), and -0.0 collapsing to 0. +# * Integers outside I-JSON's exact range (-(2^53)+1 .. 2^53-1) are +# refused, as the estate's ijson-jcs crate refuses them, because a +# consumer cannot read them back as the same number. +# * NaN and Inf are refused for the same reason. +# +# The implementation is verified against the estate's ijson-jcs crate +# (Rust, RFC 7493 + RFC 8785): the fixtures in +# test/fixtures/jcs_oracle.jsonl were produced BY that crate, and +# test/unit/test_canonical.jl asserts this module reproduces them +# byte-for-byte. The crate is the oracle; this is the second +# implementation, and any disagreement between them is a bug in one of +# them. + +module Canonical + + +using SHA + +export jcs_bytes, jcs_sha256 + +# The I-JSON exact-integer range. +const SAFE_INT_MAX = 2^53 - 1 + +""" + jcs_bytes(x) -> Vector{UInt8} + +The RFC 8785 canonical form of `x`, as bytes. Accepts the JSON-shaped +values the server produces: `Dict` and `JSON3.Object` (key order is +irrelevant; it is sorted away), `AbstractVector` and `JSON3.Array` (order +is preserved, as JCS preserves array order), `AbstractString`, `Integer`, +`AbstractFloat`, `Bool` and `nothing`. Floats serialize per ECMAScript's +Number::toString; integers outside the exact I-JSON range, and NaN or Inf +floats, throw `ArgumentError`. +""" +function jcs_bytes(x) + io = IOBuffer() + _write!(io, x) + take!(io) +end + +""" + jcs_sha256(x) -> String + +The SHA-256 of `jcs_bytes(x)`, as lowercase hex -- the content address of +a JSON value: two producers that build the same value hash identically +whatever order they built it in. +""" +jcs_sha256(x) = bytes2hex(sha256(jcs_bytes(x))) + +# Object keys sort by UTF-16 code unit, which for astral-plane characters +# means their surrogate pair, a smaller first unit than any BMP code point +# at or above U+E000. transcoding the key to UInt16 gives exactly those +# units to compare. +_utf16_units(s::AbstractString) = transcode(UInt16, String(s)) + +_write!(io, ::Nothing) = print(io, "null") +_write!(io, b::Bool) = print(io, b ? "true" : "false") + +function _write!(io, n::Integer) + # I-JSON: only |n| <= 2^53-1 survives a JSON round trip exactly. + -SAFE_INT_MAX <= n <= SAFE_INT_MAX || throw(ArgumentError( + "canonical JSON refuses the integer $n: it is outside the exact " * + "IEEE-754 binary64 range I-JSON allows")) + print(io, n) +end + +function _write!(io, x::AbstractFloat) + _write_float!(io, Float64(x)) +end + +function _write!(io, s::AbstractString) + print(io, '"') + for c in s + _write_char!(io, c) + end + print(io, '"') +end + +# ECMAScript's JSON.stringify escapes exactly these: the two mandatory +# characters, the five with short forms, and every other control +# character as \u00xx with lowercase hex. +function _write_char!(io, c::Char) + if c == '"' + print(io, "\\\"") + elseif c == '\\' + print(io, "\\\\") + elseif c == '\b' + print(io, "\\b") + elseif c == '\t' + print(io, "\\t") + elseif c == '\n' + print(io, "\\n") + elseif c == '\f' + print(io, "\\f") + elseif c == '\r' + print(io, "\\r") + elseif c < ' ' + print(io, "\\u", string(UInt32(c); base=16, pad=4)) + else + print(io, c) + end +end + +# AbstractDict (Dict and JSON3.Object alike): sorted keys, then each pair. +function _write!(io, d::AbstractDict) + print(io, '{') + first = true + for k in sort!(collect(String.(keys(d))); by = _utf16_units) + first || print(io, ',') + first = false + _write!(io, string(k)) + print(io, ':') + _write!(io, d[k]) + end + print(io, '}') +end + +# AbstractVector (Vector and JSON3.Array alike): order preserved. +function _write!(io, a::AbstractVector) + print(io, '[') + for (i, v) in enumerate(a) + i > 1 && print(io, ',') + _write!(io, v) + end + print(io, ']') +end + +# RFC 8785 §3.2.2.3: ECMAScript's Number::toString. Julia's own repr gives +# the shortest round-trip digits (Ryu, the same family ES specifies), so +# the work is reformatting those digits under ES's notation rules. +function _write_float!(io, x::Float64) + isnan(x) && throw(ArgumentError( + "canonical JSON refuses NaN: no consumer reads it back as the same value")) + isinf(x) && throw(ArgumentError( + "canonical JSON refuses Inf: no consumer reads it back as the same value")) + if x == 0 + print(io, "0") # +0.0 and -0.0 both collapse to 0, as ECMAScript does. + return + end + if signbit(x) + print(io, '-') + x = -x + end + digits, n = _shortest_digits(x) + k = length(digits) + if n > 21 || n <= -6 + # Exponential: one digit, then the fraction, then e with an + # explicit sign (ES prints "1e+30", not "1e30"). + print(io, digits[1]) + k > 1 && (print(io, '.'); print(io, digits[2:end])) + e = n - 1 + print(io, 'e', e < 0 ? '-' : '+', string(abs(e))) + elseif n >= k + # An integer too big for the fraction: digits followed by zeros. + print(io, digits, "0"^(n - k)) + elseif n > 0 + # Digits with the point inside them. + print(io, digits[1:n], '.', digits[n+1:end]) + else + # Below one, positional as far as ES goes: 0.000001, not 1e-6. + print(io, "0.", "0"^(-n), digits) + end +end + +# The shortest round-trip decimal form of x as (digits, n) with +# x = 0.digits * 10^n: repr yields either "123", "4.5" or "1.5e-10". +function _shortest_digits(x::Float64) + s = repr(x) + mantissa, exponent = occursin('e', s) ? (split(s, 'e') .|> String) : (s, "") + e = isempty(exponent) ? 0 : parse(Int, exponent) + if occursin('.', mantissa) + intpart, fracpart = split(mantissa, '.') .|> String + else + intpart, fracpart = mantissa, "" + end + digits = string(intpart, fracpart) + n = length(intpart) + e + # Trailing zeros are notation, not value: 1.0e30 is digits "1", n 31. + while length(digits) > 1 && endswith(digits, '0') + digits = digits[1:end-1] + end + digits, n +end + +end # module diff --git a/test/fixtures/jcs_oracle.jsonl b/test/fixtures/jcs_oracle.jsonl new file mode 100644 index 00000000..dd446d8b --- /dev/null +++ b/test/fixtures/jcs_oracle.jsonl @@ -0,0 +1,11 @@ +{"input":{"b":1,"a":2,"c":[]},"expected":"{\"a\":2,\"b\":1,\"c\":[]}"} +{"input":{"€":"euro","A":"fullwidth A","a":"first"},"expected":"{\"a\":\"first\",\"€\":\"euro\",\"A\":\"fullwidth A\"}"} +{"input":{"😀":"astral first?","A":"high bmp"},"expected":"{\"😀\":\"astral first?\",\"A\":\"high bmp\"}"} +{"input":{"d":[3,1,2],"e":{"z":1,"y":{"x":[true,false,null]}}},"expected":"{\"d\":[3,1,2],\"e\":{\"y\":{\"x\":[true,false,null]},\"z\":1}}"} +{"input":[4.50,2.4499999999999998e16,1E+30,0.000001,1E-7,1E+21,1E+20,1E-33,-0.0,0.0,9.9999999999999997E+22],"expected":"[4.5,24500000000000000,1e+30,0.000001,1e-7,1e+21,100000000000000000000,1e-33,0,0,1e+23]"} +{"input":[0.000001,1E-7,1.5E-10,9007199254740991,-42],"expected":"[0.000001,1e-7,1.5e-10,9007199254740991,-42]"} +{"input":{"s\":with\"quotes":"tab\there\nnewline","esc\\":"\\backslash","ctl":"\u0001\u001f"},"expected":"{\"ctl\":\"\\u0001\\u001f\",\"esc\\\\\":\"\\\\backslash\",\"s\\\":with\\\"quotes\":\"tab\\there\\nnewline\"}"} +{"input":{"nested":{"arr":[{"n":1.0},{"m":2}],"deep":{"deeper":{"deepest":"value"}}}},"expected":"{\"nested\":{\"arr\":[{\"n\":1},{\"m\":2}],\"deep\":{\"deeper\":{\"deepest\":\"value\"}}}}"} +{"input":{"literals":true,"n":null,"f":false,"empty_obj":{},"empty_arr":[]},"expected":"{\"empty_arr\":[],\"empty_obj\":{},\"f\":false,\"literals\":true,\"n\":null}"} +{"input":[5E-324,1.7976931348623157E+308,2.2250738585072014E-308,0.3,0.1,0.30000000000000004],"expected":"[5e-324,1.7976931348623157e+308,2.2250738585072014e-308,0.3,0.1,0.30000000000000004]"} +{"input":{"Plotly":{"layout":{"title":{"text":"CO₂"}},"data":[{"x":[1,2,3],"y":[4.5,5.5,6.5],"type":"scatter"}]}},"expected":"{\"Plotly\":{\"data\":[{\"type\":\"scatter\",\"x\":[1,2,3],\"y\":[4.5,5.5,6.5]}],\"layout\":{\"title\":{\"text\":\"CO₂\"}}}}"} diff --git a/test/runtests.jl b/test/runtests.jl index d107e559..77e15bcf 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -54,6 +54,7 @@ const UNIT_FILES = [ "test_permanova.jl", "test_write_gates.jl", "test_server_security.jl", + "test_canonical.jl", ] # The files named on the command line ("test_routes.jl", "routes" or diff --git a/test/unit/test_canonical.jl b/test/unit/test_canonical.jl new file mode 100644 index 00000000..1a9bb01b --- /dev/null +++ b/test/unit/test_canonical.jl @@ -0,0 +1,74 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Unit tests for the JCS (RFC 8785) canonical JSON writer. The oracle is the +# estate's ijson-jcs crate (Rust): every fixture in +# test/fixtures/jcs_oracle.jsonl was PRODUCED by that crate, and this file +# asserts MetaManifold.Canonical reproduces it byte-for-byte. Any +# disagreement is a bug in one of the two implementations, and the fixture +# names the case. +using MetaManifold +using MetaManifold.Canonical: jcs_bytes, jcs_sha256 +using JSON3, SHA, Test + +@testset "canonical JSON (JCS)" begin + # Byte-for-byte against the Rust crate's output, over the fixture + # corpus: key sorting (including the astral-before-high-BMP UTF-16 + # case), ES number formatting, string escaping, nesting, literals. + fixtures = joinpath(@__DIR__, "..", "fixtures", "jcs_oracle.jsonl") + for (i, line) in enumerate(eachline(fixtures)) + case = JSON3.read(line) + @testset "oracle case $i" begin + value = case.input + # The input arrives already parsed; the expected is the oracle's text. + @test String(jcs_bytes(value)) == case.expected + end + end + + @testset "key order is irrelevant, bytes are not" begin + a = Dict("b" => 1, "a" => Dict("y" => [1.0, 2], "x" => "s")) + b = Dict("a" => Dict("x" => "s", "y" => [1.0, 2]), "b" => 1) + @test jcs_bytes(a) == jcs_bytes(b) + # ...but array order is preserved, as JCS requires. + c = Dict("a" => [2, 1]) + @test jcs_bytes(a) != jcs_bytes(c) + end + + @testset "the astral key sorts before the high BMP key" begin + # U+1F600 arrives as the surrogate pair D83D DE00; UTF-16 code-unit + # order puts it before U+FF21. Naive code-point order would agree + # here; naive byte order would too -- this case pins the rule. + d = Dict("\uff21" => "high bmp", "\U0001f600" => "astral") + @test String(jcs_bytes(d)) == "{\"😀\":\"astral\",\"A\":\"high bmp\"}" + end + + @testset "float notation follows ECMAScript" begin + @test String(jcs_bytes([4.50, 2.4499999999999998e16, 1e30, 1e-6, 1e-7, + 1e21, 1e20, -0.0, 5e-324])) == + "[4.5,24500000000000000,1e+30,0.000001,1e-7,1e+21,100000000000000000000,0,5e-324]" + end + + @testset "refusals" begin + @test_throws ArgumentError jcs_bytes(2^53) + @test_throws ArgumentError jcs_bytes(-(2^53)) + @test_throws ArgumentError jcs_bytes(Inf) + @test_throws ArgumentError jcs_bytes(NaN) + @test jcs_bytes(2^53 - 1) == b"9007199254740991" + end + + @testset "a content address is stable" begin + # The use this exists for: a chart-like spec built in any key + # order hashes identically, and the hash can be recorded where the + # bytes cannot. + spec = Dict( + "layout" => Dict("title" => Dict("text" => "CO₂")), + "data" => [Dict("x" => [1, 2, 3], "y" => [4.5, 5.5, 6.5], + "type" => "scatter")]) + same = Dict( + "data" => [Dict("type" => "scatter", "y" => [4.5, 5.5, 6.5], + "x" => [1, 2, 3])], + "layout" => Dict("title" => Dict("text" => "CO₂"))) + @test jcs_sha256(spec) == jcs_sha256(same) + @test jcs_sha256(spec) == bytes2hex(sha256(jcs_bytes(spec))) + end +end diff --git a/test/unit/test_install_pins.jl b/test/unit/test_install_pins.jl index 93f63624..9c340b57 100644 --- a/test/unit/test_install_pins.jl +++ b/test/unit/test_install_pins.jl @@ -325,7 +325,9 @@ end "README.md", "LICENSE", "CITATION.cff", # The two entry points a newcomer runs (the launcher stays at the # root); the installer itself lives under scripts/. - "install.sh", "start.sh", + "install.sh", "start.sh", "launcher.sh", + # The RSR scheme's task entry and changelog index. + "Justfile", "CHANGELOG.md", # Build metadata rooted by its tooling: Julia Pkg, and renv, which # also scans R/ for dependency discovery. "Project.toml", "Manifest.toml", "renv.lock", "renv", ".Rprofile", "R", @@ -338,8 +340,8 @@ end # is listed here rather than living under .github/ with the other # health files. "AGENTS.md", - # VCS housekeeping. - ".gitignore", + # VCS housekeeping, and the editor configuration. + ".gitignore", ".editorconfig", ] tracked = Set(split(readchomp(`git -C $(REPO_ROOT) ls-tree --name-only HEAD`), "\n")) for entry in root_entries @@ -381,3 +383,21 @@ end end end end + +@testset "KYAML is the authoring dialect, ratcheted" begin + # YAML-POLICY Y-3 (standards, owner rulings D280/D307): KYAML is the + # estate's authoring dialect; a *.kyaml file that is not in KYAML form + # is a violation, an unconverted *.yml is migration backlog. The + # formatter is the standards repo's kyaml-format.sh, verbatim: the one + # rewriter the step-2 comment-preservation proof covers. Tool-named + # files (workflows, dependabot, codecov) keep their extensions; the + # shared config defaults convert as an upstream unit, not unilaterally. + formatter = joinpath(REPO_ROOT, "scripts", "kyaml-format.sh") + tracked = split(readchomp(`git -C $(REPO_ROOT) ls-files`), "\n") + kyamls = [joinpath(REPO_ROOT, f) for f in tracked if endswith(f, ".kyaml")] + for path in kyamls + proc = run(pipeline(ignorestatus(`bash $formatter --check $path`), + stdout = devnull, stderr = devnull)) + @test success(proc) + end +end