Compare commits

...

3 commits

Author SHA1 Message Date
3c9ec72b88
add nix flake/fix release mode tests
All checks were successful
Build / Engine, CLI and TUI (push) Successful in 1m56s
Build / Android (push) Successful in 5m59s
Build / Sign (push) Successful in 3m38s
Build / Publish (push) Successful in 12s
2026-10-04 15:01:16 -07:00
95b2db5e02
lock in apk cert 2026-10-04 12:40:43 -07:00
59feb4b5e8
align markdown table in SETUP 2026-10-04 08:51:39 -07:00
9 changed files with 267 additions and 34 deletions

View file

@ -55,13 +55,27 @@ jobs:
# Engine, CLI, TUI, the C ABI from Zig, the JNI layer against a synthetic
# function table, and the C ABI from C through the installed header.
run: zig build test --summary all
- name: Run tests in each release mode
# What ships is not Debug: the CLI and TUI are ReleaseSafe, the Android library
# ReleaseSmall. Debug fills undefined memory with 0xaa, so code that reads
# something it never wrote can pass there by luck and fail in a release build:
# the JNI tests did exactly that. ReleaseFast has no safety checks at all, which
# is where such a read shows up most readily, so it runs too.
run: |
for mode in ReleaseSafe ReleaseSmall ReleaseFast; do
echo "== $mode"
zig build test -Doptimize="$mode" --summary all
done
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Prove the JNI table against a real JVM
run: zig build jvm-test --summary all
# Debug, and ReleaseSmall: the optimization the Android library ships with.
run: |
zig build jvm-test --summary all
zig build jvm-test -Doptimize=ReleaseSmall --summary all
- name: Package the CLI and TUI
# One binary holds both. Static musl on Linux, so it runs on any distribution.
run: |
@ -181,13 +195,15 @@ jobs:
# included, each logged to sigstore. One power cycle of the HSM for all of it.
# The APK's signature is the app's identity on every device that installs it, for
# good: the key is the HSM's, and its certificate is the one `make-cert` wrote to
# the token (README, "Signing").
# the token (android/signing-cert.pem; README, "Signing"). apk_cert_sha256 pins
# it: an APK signed with any other certificate fails here instead of shipping.
id: sign
uses: https://git.lerch.org/lobo/action-hsm-sign@v3
uses: https://git.lerch.org/lobo/action-hsm-sign@v3.1
with:
pin: ${{ secrets.HSM_USER_PIN }}
apk: unsigned/app-release-unsigned.apk
apk_output: dist/tally.apk
apk_cert_sha256: '4b41d0b1b3a184342fdc348e67563c8ca6cbc3765fa7341dee8a4a8fbb5d4aad'
files: dist/*
public_key: 'https://emil.lerch.org/serverpublic.pem'
uhub_control: 'true'

4
.gitignore vendored
View file

@ -2,6 +2,10 @@
.zig-cache/
zig-out/
# nix build output links
result
result-*
# Gradle and Android build artifacts
android/.gradle/
android/.kotlin/

View file

@ -104,6 +104,35 @@ Android ties an app to its signing key permanently, so a published APK cannot up
copy you built yourself (which is signed with your debug key), and the reverse. Uninstall
one before installing the other; the app's saved state goes with it.
### With nix
This repository is a flake, for the CLI and TUI (the Android app stays with gradle). To
add it to a `buildEnv`-style home profile:
```nix
inputs = {
tally.url = "git+https://git.lerch.org/lobo/tally.git";
# Optional: reuse your own nixpkgs instead of instantiating another one.
tally.inputs.nixpkgs.follows = "nixpkgs";
};
```
then add `tally.packages.${system}.default` to your package list. Or run it directly:
```
nix run git+https://git.lerch.org/lobo/tally.git -- '2^100 + 1'
```
The package is the `tally` binary, plus the engine as `lib/libtally-engine.a`,
`lib/libtally.so` and `include/tally.h`. `nix flake update tally` picks up a new version.
The build runs `zig build test` in the sandbox, in ReleaseSafe like the binary it
installs, so a version that fails its tests will not install. `nix develop` gives a shell
with Zig 0.16 and zls.
Changing the dependencies in `build.zig.zon` invalidates the `zigDeps` hash in
`nix/package.nix`. To refresh it: set the hash to `lib.fakeHash`, build, and paste the
hash nix reports.
## Building
Every tool comes from [mise](https://mise.jdx.dev), pinned in [.mise.toml](.mise.toml):
@ -186,9 +215,13 @@ A few decisions shape everything else, and the design document explains each:
- `zig build test`: about a thousand Zig tests across the engine, CLI, TUI (rendering
real frames and reading the cells back), the C ABI, and the JNI layer against a
synthetic function table; plus a C program that links the shared library through
`tally.h`, which catches a header that disagrees with the library.
`tally.h`, which catches a header that disagrees with the library. CI also runs it with
`-Doptimize=ReleaseSafe`, `ReleaseSmall` and `ReleaseFast`: what ships is not Debug,
and Debug's 0xaa fill of undefined memory can hide a read of something never written
(it hid one in the JNI tests).
- `zig build jvm-test`: the JNI entry points from Java, which is the only way to check
the function-table indices without a device.
the function-table indices without a device. CI runs it in Debug and in ReleaseSmall,
the optimization the Android library ships with.
- `cd android && gradle testDebugUnitTest`: JVM tests of the app's rules (the converter,
programmer mode, saved state), no device needed.
- `cd android && gradle connectedAndroidTest`: the engine through the real app on a
@ -203,12 +236,12 @@ punctuation: the source is ASCII.
[.forgejo/workflows/build.yaml](.forgejo/workflows/build.yaml) runs on every push:
| Job | Runner | What |
|---------------------|--------------------------|---------------------------------------------------------------------------------------------------------------------------------------------|
| Engine, CLI and TUI | `ubuntu-latest` | `zig fmt --check`, zlint, build, `zig build test`, `zig build jvm-test`, then release builds for x86_64 and aarch64 Linux and aarch64 macOS |
| Android | `ubuntu-latest` | `zig build android`, the library audit, the JVM unit tests, debug and unsigned release APKs (with lint's release checks) |
| Sign | `ubuntu-latest-with-hsm` | master only: signs the APK with the HSM, then every release file with a detached signature logged to sigstore |
| Publish | `ubuntu-latest` | master only: uploads the signed files to the generic package registry under the short SHA and `latest` |
| Job | Runner | What |
|---------------------|--------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Engine, CLI and TUI | `ubuntu-latest` | `zig fmt --check`, zlint, build, `zig build test` in Debug and each release mode, `zig build jvm-test` in Debug and ReleaseSmall, then release builds for x86_64 and aarch64 Linux and aarch64 macOS |
| Android | `ubuntu-latest` | `zig build android`, the library audit, the JVM unit tests, debug and unsigned release APKs (with lint's release checks) |
| Sign | `ubuntu-latest-with-hsm` | master only: signs the APK with the HSM, then every release file with a detached signature logged to sigstore |
| Publish | `ubuntu-latest` | master only: uploads the signed files to the generic package registry under the short SHA and `latest` |
Every job reports to ntfy. Zlint and Gradle are downloaded at pinned versions and checked
against pinned SHA-256 digests; Zig's version comes from `build.zig.zon`.
@ -225,28 +258,47 @@ included. The action power cycles the HSM on its smart USB hub for the duration,
the private key never leaves the HSM.
An APK signature carries a certificate, and Java only offers a PKCS#11 key that has one
on the token. **Once, before the first signed build**, make a self-signed certificate for
the key and store it beside the key. On the HSM host, with the HSM powered, from a clone
of action-hsm-sign:
on the token; whatever certificate the token holds for the key is used. It is not
Tally's own: the token holds one certificate per key, so every app signed with the key
carries it, and its subject is generic. It is the identity of all of them on every
device: an APK signed with a different one is refused as an update, and users uninstall
and reinstall. So it is chosen once, kept in this repository as
[android/signing-cert.pem](android/signing-cert.pem) (it is public), and pinned in the
workflow by its SHA-256 (`apk_cert_sha256`), so a certificate replaced on the token fails
the build instead of silently changing the app's identity.
It was made on the HSM host, with the HSM powered, from a clone of action-hsm-sign. The
certificate is assembled in the container and signed on the token with the user PIN, so
the key never leaves it, and written to the card with the Admin PIN (the card's own
rule for certificates). `REPLACE_CERT=1` because the key already had a certificate (the
card's 2023 "AUT certificate", which signed the very first build, 255f239):
```
docker build -t hsm-signer signer
read -rs PKCS11_PIN && export PKCS11_PIN
read -rs ADMIN_PIN && export ADMIN_PIN
docker run --rm -v /run/pcscd/pcscd.comm:/run/pcscd/pcscd.comm:ro \
-e PKCS11_PIN hsm-signer make-cert '/CN=Tally/O=lerch.org'
-e PKCS11_PIN -e ADMIN_PIN hsm-signer check-pin
docker run --rm -v /run/pcscd/pcscd.comm:/run/pcscd/pcscd.comm:ro \
-e PKCS11_PIN -e ADMIN_PIN -e REPLACE_CERT=1 hsm-signer make-cert '/CN=Emil Lerch/O=lerch.org'
```
It prints the certificate; keep a copy. The key is `03` by default, the one the detached
signatures use (`KEY_ID` changes it). The certificate is the app's identity on every
device from then on: replacing it means users uninstall and reinstall. Its 30-year
validity does not matter for APKs, whose certificate dates Android does not check.
`check-pin` tries each PIN once first; a wrong PIN costs a try, and the card locks a PIN
after three. `make-cert` prints the certificate and its `sha256 (apk_cert_sha256)`:
`4b41d0b1b3a184342fdc348e67563c8ca6cbc3765fa7341dee8a4a8fbb5d4aad` (`CN=Emil Lerch,
O=lerch.org`, valid to 2056). The key is `03` by default, the one the detached
signatures use (`KEY_ID` changes it); a new certificate does not change the key, so the
detached signatures and `serverpublic.pem` are unaffected. Its 30-year validity does not
matter for APKs, whose certificate dates Android does not check, but is long enough for
Google Play (validity past 2033-10-22).
`mise run apk-signer-test` builds the release APK and runs action-hsm-sign's
`signer/test.sh` on it, at the version the workflow uses (or a local checkout named by
`ACTION_HSM_SIGN`). That is the same signer image against SoftHSM: a token with a bare
key, a detached signature, `make-cert`, APK signing, `apksigner verify`, and a check that
the APK's signing key is the token's. What it cannot cover is the HSM itself and the
host's pcscd, so the first run on the HSM runner is the real test of those.
key, a detached signature, `make-cert` (and its refusal to replace a certificate), APK
signing with the certificate pinned, `apksigner verify`, and a check that the APK's
signing key is the token's. What it cannot cover is the HSM itself and the host's pcscd,
so the first run on the HSM runner is the real test of those.
## License

View file

@ -7,11 +7,11 @@ manager outside mise is involved, and no NDK is needed at any point.
It is not mise alone, and the distinction matters when something breaks.
| Layer | Provided by | Pinned? | Lives in |
|---|---|---|---|
| Zig, JDK, Gradle, SDK command-line tools | mise, from `.mise.toml` | yes, by version | `~/.local/share/mise/installs/` |
| Layer | Provided by | Pinned? | Lives in |
|-------------------------------------------------------------------------------|----------------------------------------|--------------------|-------------------------------------------------|
| Zig, JDK, Gradle, SDK command-line tools | mise, from `.mise.toml` | yes, by version | `~/.local/share/mise/installs/` |
| SDK packages: platforms, build-tools, platform-tools, emulator, system images | `sdkmanager`, via the mise tasks below | partly - see below | inside the mise `android-sdk` install directory |
| The AVD (the emulator's virtual device) | `avdmanager`, via a mise task | n/a | `.tmp/avd` in this repo, gitignored |
| The AVD (the emulator's virtual device) | `avdmanager`, via a mise task | n/a | `.tmp/avd` in this repo, gitignored |
`mise install` is necessary but not sufficient. It gives you `sdkmanager`; it does not give
you an SDK you can build against. mise has no idea the SDK packages exist, so they are not

28
android/signing-cert.pem Normal file
View file

@ -0,0 +1,28 @@
-----BEGIN CERTIFICATE-----
MIIE2zCCAsMCFHrZq0V4qQfkUv0emf3CkUnuZztZMA0GCSqGSIb3DQEBCwUAMCkx
EzARBgNVBAMMCkVtaWwgTGVyY2gxEjAQBgNVBAoMCWxlcmNoLm9yZzAgFw0yNjEw
MDQxOTE1MjhaGA8yMDU2MDkyNjE5MTUyOFowKTETMBEGA1UEAwwKRW1pbCBMZXJj
aDESMBAGA1UECgwJbGVyY2gub3JnMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC
CgKCAgEA0rlFG2I641ARuKWcyFVrL81pwP8O0KGSd7uYkLpf4F3ut6VUZOsaGzCz
fACXu6x57DFH1DMZkaf71vg+btRUw/b4eGmTXiAfq5D4CM5Bk1kgxOqat5JL+YF1
EunxYV3EqAhtaL9FLrafNL+guX/slQ7ol8RjlWddJqkS+tpoHu6XrmMgChosDfHY
cZeGI/B8wkiD4sLVy/VwnlyGPK1Ww4o/EnIwiRH6m3bP/bxfKslXg+Z6mUch530F
4O75fhUzBzzrvbluKEH+9uWviVHHdNM3NSmNX5xYqaVr9OdiMHO18lynNZK8DBW4
vsrpGO5GX0F5spafa5bBzTPdtJw52v5Mw5pTtKtocwn9ML26lq4KEIBW5aANKvVc
hcyFOELJFMzvuE4pxvVzM0SlpG7p7hU7cz7kE83QnZzGup1d9vxpCbBtDIsaB+B5
/43YHLc+mTCzTdHNRy91RwYiZKOL3zEsMyWHJXwoO0ELaET0gmrRymbEETdCoWNP
qVKxea+zuPpeGGamIPoATgxnVAgfYlnFhSiMDuwERiJ1HIUqlcVaFAETLw/jdvih
TgS7FJ7xfZy8+nT+sQt7P2s77A8ogX+NsRyMNic8/pCVOkfWYGCf5n5aOu9EGXiR
LHn1X28ZJIfuxJ16JlclkIxnBHp4okJ1mYWHuN2SRFvXu6tCOrkCAwEAATANBgkq
hkiG9w0BAQsFAAOCAgEAOwGr7hXIv9+2bx4ngGYAq9FRJeAesaG4inn8L9eSQq7S
peJbtyL4BO5/wAZt6gD/qTVWRVFiaLDvs2oyoh+mv74TsAyYHUFKHVptMOaG6OBu
3rGdDbQFKpi62/v+aC9zakgNx4oH+0Ev/WbWf3u+VXlZGnF51eBZh3E7B4DW5TkG
sMEaXP1wOMdx2cZWg0e84qaHNi4eSUIgB0QSwbXlw2AZEcTVcqAws7sLvhUgphxG
aH9n2+a0CCfPZRlyJFJlQ+DXohOOjVENG++PbxPeMmRi0EvmVAhbnrMGhVi6ayxH
KmoRwOfoe+Ti6kStPGaYXLIVVA2BKnBCYL3pi/ZWCKSsueJuKLUYzddsZe9exirr
y8/Jpct3KvlYaA4dMIaUlYRAGn609hWt7sAqNEnkSHsjqtycPbAi74+AulUpdcWO
GL+77YiCx1I4+rXBFLV43kz+Fw0A9btdrv4/gC+siAzDYv7Y21+rM1IxAU+xnHnm
btvc04C6CDG2UpPSSdJRm9yugtXk7AH4AtboKUvpCWbwHK0Kd9kAUbJ0Mbbuxf13
7YppxMGGSSJjG+XV917tSWG+gNNz2V9KLxsGnCJIRRHaGsBfn8p+TY05PvRFcj7Z
vxxEGvrZMBaQz2noQjKsoAsWjr199mfFKm4/rxHpHe3vI3kzfCHaU0MVVCUGrtk=
-----END CERTIFICATE-----

View file

@ -508,20 +508,24 @@ const FakeJvm = struct {
var current: ?*FakeJvm = null;
/// Puts every field in its starting state, defaults included: the tests declare the
/// fake `undefined` and call this, so a field set nowhere here would be whatever the
/// stack held. It was: `misplace_new_string` was never reset, Debug's 0xaa fill
/// happened to read as false, and release builds, which do not fill, failed the JNI
/// tests whenever the leftover byte was odd.
fn init(self: *FakeJvm) void {
self.table = .{ .entries = @splat(null) };
self.* = .{
.table = .{ .entries = @splat(null) },
.vm_table = .{ .entries = @splat(null) },
// Addresses inside `self`, which is where this value is being written.
.env = .{ .functions = &self.table },
.vm = .{ .functions = &self.vm_table },
};
self.table.entries[@intFromEnum(EnvIndex.get_version)] = @ptrCast(&getVersion);
self.table.entries[@intFromEnum(EnvIndex.new_string_utf)] = @ptrCast(&newStringUtf);
self.table.entries[@intFromEnum(EnvIndex.get_string_utf_chars)] = @ptrCast(&getStringUtfChars);
self.table.entries[@intFromEnum(EnvIndex.release_string_utf_chars)] = @ptrCast(&releaseStringUtfChars);
self.env = .{ .functions = &self.table };
self.vm_table = .{ .entries = @splat(null) };
self.vm_table.entries[@intFromEnum(VmIndex.get_env)] = @ptrCast(&getEnv);
self.vm = .{ .functions = &self.vm_table };
self.made_len = 0;
self.releases = 0;
current = self;
}

26
flake.lock generated Normal file
View file

@ -0,0 +1,26 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1791142733,
"narHash": "sha256-x1jawtfPqMTQYpt1d6H+9rxXweqTzU5hYhdfz0T5MPM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "466eaf945dddfd32abd006bd94cc55660fdb56c8",
"type": "github"
},
"original": {
"id": "nixpkgs",
"ref": "master",
"type": "indirect"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

46
flake.nix Normal file
View file

@ -0,0 +1,46 @@
{
description = "Calculator with exact arithmetic, unit conversion and a programmer mode, as a CLI and TUI";
inputs.nixpkgs.url = "nixpkgs/master";
outputs = { self, nixpkgs }:
let
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
# Helper to generate an attrset '{ x86_64-linux = f "x86_64-linux"; ... }'.
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
in
{
packages = forAllSystems (system:
let
pkgs = nixpkgs.legacyPackages.${system};
tally = pkgs.callPackage ./nix/package.nix {
# Pinned deliberately rather than tracking `pkgs.zig`: build.zig.zon
# declares 0.16.0 as the minimum, and 0.16 was a large standard
# library refactor, so a newer default would not build unchanged.
zig = pkgs.zig_0_16;
# The git-tracked tree, which keeps .zig-cache, zig-out, zig-pkg and
# the Android build outputs out of the derivation.
src = self;
};
in
{
inherit tally;
default = tally;
});
devShells = forAllSystems (system:
let pkgs = nixpkgs.legacyPackages.${system};
in
{
# `nix develop` for the CLI/TUI without mise. The versions here are the
# same ones .mise.toml pins; the Android toolchain stays with mise.
default = pkgs.mkShell {
packages = [
pkgs.zig_0_16
pkgs.zls
];
};
});
};
}

57
nix/package.nix Normal file
View file

@ -0,0 +1,57 @@
{ lib, stdenv, zig, src }:
stdenv.mkDerivation (finalAttrs: {
pname = "tally";
# Kept in step with build.zig.zon.
version = "0.1.0";
inherit src;
__structuredAttrs = true;
strictDeps = true;
# Zig resolves build.zig.zon dependencies (libvaxis) over the network, which the
# build sandbox forbids. `fetchDeps` runs `zig build --fetch` inside a fixed-output
# derivation, so the fetch happens once against a known hash and the real build
# stays offline.
#
# This hash changes whenever build.zig.zon's dependencies change. To update it: set
# `lib.fakeHash`, run the build, and paste the hash nix reports.
zigDeps = zig.fetchDeps {
inherit (finalAttrs) pname version src;
fetchAll = true;
hash = "sha256-Wy/H3z2dBsF/WTF2LrCofOuE15pbgdwEJLSUrKCOQGI=";
};
postConfigure = ''
# A writable copy rather than a symlink into the store: Zig writes cache
# metadata alongside the fetched packages while it verifies them.
cp -rLT ${finalAttrs.zigDeps} "$ZIG_GLOBAL_CACHE_DIR/p"
chmod -R u+w "$ZIG_GLOBAL_CACHE_DIR/p"
'';
nativeBuildInputs = [ zig ];
# The zig setup hook's check phase runs `zig build test`, with the same flags as the
# build (ReleaseSafe): the engine, CLI, TUI, C ABI and JNI tests, and the C program
# linked through tally.h. None of them touch the network or anything outside the
# build directory, so every rebuild is a test run. (`jvm-test`, `coverage` and
# `android` are separate steps, not run here.)
doCheck = true;
meta = {
description = "Calculator with exact arithmetic, unit conversion and a programmer mode, as a CLI and TUI";
longDescription = ''
One Zig engine behind a command line, a terminal UI and an Android app:
exact rational arithmetic where the operations allow it, programmer mode
(8 to 128-bit integers in every base), unit conversion as part of the
expression language, and financial functions. This package is the CLI/TUI
binary, plus the engine as a static library and as a shared library with
its C header (tally.h).
'';
homepage = "https://git.lerch.org/lobo/tally";
license = lib.licenses.mit;
mainProgram = "tally";
platforms = lib.platforms.unix;
};
})