This guide covers day-to-day local development against the Cargo workspace and how to cut
tagged, reproducible releases. All commands are run from the rust/ directory, where the
workspace Cargo.toml and the Justfile live. It mirrors the Go side
(go/docs/development.md); the differences are called out
where they matter.
Every crate is versioned solely by its own git tag rust/<crate>/vX.Y.Z, bumped only
when that crate changes. There is no shared version: the workspace deliberately has no
workspace.package.version, an unchanged crate keeps its tag, and the number just reflects
that crate's own history. The version in each crate's Cargo.toml always equals its
latest tag (or the upcoming one, on the release commit).
- Local development uses the workspace
Cargo.toml, which plays the role ofgo.work: every in-repo crate resolves from local source through thepathin[workspace.dependencies], so a library edit is used immediately by every crate that depends on it. Unlikego.workthere is nothing to opt out of — Cargo always builds a workspace member from its path. - Sibling crates are resolved by
version, not by git tag. Each in-repo entry in[workspace.dependencies]carries both apathand aversion(ti-pki = { version = "0.1", path = "ti-pki" }). The path is used locally; the version is what a packaged or published crate records for its dependency. A library bump therefore has to update that line together with the crate version and release notes. This replacesjust syncfrom the Go side. - Rust code always uses the snake_case form of a crate name: the package is
ti-pki, the import isuse ti_pki::...;.
Inspect and manage versions:
just versions # latest tag per crate
just changed # crates with commits since their last tag (candidates to bump)
just tag <crate> <ver> # e.g. just tag ti-pki 0.1.0 -> tags rust/ti-pki/v0.1.0git clone https://github.com/gematik/zero-lab.git
cd zero-lab/rust # the workspace Cargo.toml lives here
just tools # once: the pinned cargo tools
# Edit a crate
$EDITOR ti-pki/src/chain.rs
# Build / test — the change is already in effect for every workspace member
cargo build --workspace
cargo test -p ti-pkiConfirm the workspace is wiring local source:
cargo metadata --format-version 1 | jq -r '.packages[] | select(.name == "ti-pki") | .manifest_path'
# => …/zero-lab/rust/ti-pki/Cargo.toml (a local path = the workspace resolving it locally)Checks come in two tiers:
just check-changed # while working: fmt, clippy, doc and tests of the crates a branch changed and their dependents; wasm32, nonprod-absent, the manifest checks and the scripts' tests only when their inputs changed
just check # tier 1, once per branch before it merges: fmt, clippy, doc, test, wasm32, core-deps, nonprod-absent, machete, deny, vet
just check-full # tier 1, before a release: check, the tests with default features, every feature combination, clippy on the CLI's cross targets (Docker)
just audit # tier 2, before tagging: advisories, vet, msrv, semverEach gate just release runs also runs earlier, so a release does not fail first on it
(the mutation scores excepted, which just mutants-record measures beforehand, by hand):
vet in check, the Linux and Windows builds of ti as cli-check in check-full
(linted in the same cross containers cli-targets builds in), and the advisory database
through deny in check (advisories, cargo audit, in audit). msrv and semver run
in audit only.
Shared metadata (edition, rust-version, license, repository, authors, publish) lives in
[workspace.package] and lints in [workspace.lints]; a crate's Cargo.toml only states
its name, version, description, keywords and categories, and inherits the rest with
<field>.workspace = true. External dependencies are pinned once in
[workspace.dependencies] and inherited the same way. Cargo.lock is committed. Adding or updating a dependency makes
just vet fail until the new version is audited (cargo vet certify) or covered by an
imported audit; just vet-suggest lists what is outstanding.
Vocabulary every ti-* crate must agree on — today the environment (Env) and its
production split (Tier) — lives in ti-types. Its admission rule:
If it compiles in
no_stdand has no I/O, it may go intoti-types; otherwise it doesn't.
No HTTP, no crypto, no async, no product constants (URLs, anchors), no helper functions; the default build has no dependencies.
ti-types goes to 1.0 soon and is additive only from then on. Every enum is
#[non_exhaustive], except Tier, whose split is binary by definition. A breaking
redesign gets a new type instead of a major bump: a major bump would split consumers into
incompatible Env types that no longer interoperate.
Environments are data, not behaviour. Library code never branches on Env; everything
that differs between environments — anchor, roots.json, TSL location, the policy
relaxations — is a named field of ti_pki::TrustConfig, and Env only picks a preset.
The one decision code may make is the Tier, and it is made in one place:
TrustConfig::validate(tier) rejects TEST-ONLY anchors and every relaxation under
Tier::Prod.
The non-production presets and their TEST-ONLY anchors and roots are compiled in only
with the dangerous-nonprod feature. A production binary is built without it, and
just nonprod-absent proves the compiled crate then contains none of those bytes.
Operators override fields on a preset rather than adding environments — a TSL mirror, a tighter clock skew, an anchor delivered out of band:
let config = TrustConfig { tsl_url: mirror.into(), ..TrustConfig::preset_prod() };
config.validate(Tier::Prod)?;Tests in downstream crates use TrustConfig::for_lab_ca from the test-util feature.
Every signature ti-pki checks goes through one trait,
rustls_pki_types::SignatureVerificationAlgorithm, the one rustls and webpki use. A
verifier is picked by the exact pair of key algorithm and signature algorithm;
validation code never names a curve. TrustConfig::algorithms holds the set.
| Set | Contents | Backend |
|---|---|---|
algorithms::STANDARD |
ECDSA P-256/SHA-256, P-384/SHA-384 | RustCrypto p256, p384 |
algorithms::brainpool::ALL (feature brainpool, default) |
ECDSA brainpoolP256r1/SHA-256, brainpoolP384r1/SHA-384 | RustCrypto bp256, bp384 |
algorithms::rsa::ALL (feature rsa, default) |
RSA PKCS#1 v1.5 and PSS with SHA-256/384/512 | RustCrypto rsa |
algorithms::DEFAULT |
STANDARD plus brainpool and RSA when enabled; what the presets use |
— |
algorithms::NIST |
STANDARD plus RSA when enabled, never brainpool; how a client without brainpool verifies |
— |
Brainpool is isolated: its own module behind its own feature, referenced only by
DEFAULT, and just core-deps proves the crate without it contains no brainpool
arithmetic. The anchor of every environment is GEM.RCA7 (P-256), but most roots and CAs
and both TSL signer CAs are brainpool keys, so the feature is on by default. Without
brainpool (the feature off, or NIST configured) the roots walk ends at the first
brainpool root, GEM.RCA8 forwards and GEM.RCA5 backwards, leaving GEM.RCA7 and GEM.RCA6,
and no TSL verifies (TSLDET-004), which fails a load of roots and TSL together;
validate still refuses a brainpool anchor instead of failing later. Other
implementations — webpki's FIPS-validated aws-lc-rs set, ML-DSA once post-quantum
certificates appear — plug into the same set without touching the core.
bp256/bp384 gained curve arithmetic only in 0.14 (September 2026) and are not
audited. Verification handles public data, so timing is irrelevant; correctness is
covered by the Wycheproof vectors (ti-pki/tests/wycheproof/, excluded from the package)
and by the real TI roots in the unit tests.
RSA is needed even for an ECC-only deployment: the historical roots GEM.RCA2/6/9 are RSA
keys, and the A_28419 walk passes through them to reach GEM.RCA3–5 and GEM.RCA10–11.
Without the rsa feature the walk stops at the first RSA root. With it, ti-pki builds
the same ten-root store as gempki from the same roots.json.
Accepted trade-offs of a long-lived project whose dependencies are still maturing. Each names the trigger for removing it.
| Compromise | Why | Remove when |
|---|---|---|
rsa = "=0.10.0-rc.19", a release candidate |
the only rsa line on the current RustCrypto stack (crypto-bigint 0.7, sha2 0.11); 0.9 would pull in a second, older stack |
rsa 0.10 is released: switch to "0.10" |
ti releases carry unsigned SHA256SUMS |
signing was deferred; the checksums catch broken downloads and Homebrew pins them in the formula, but a replaced release could replace them too | a release key exists: sign SHA256SUMS in just release and verify it in publish-brew |
RUSTSEC-2023-0071 ignored (deny.toml, .cargo/audit.toml) |
the Marvin attack targets RSA private-key operations; ti-pki only verifies, on public data |
a patched rsa is released |
The TSL signer CA does not change at run time: spec/tsl-xmldsig part E (TSLSIG-061 – 068: an anchor announced in the TSL becoming active at its StatusStartingTime, and the fallback through roots.json) is not implemented. TrustConfig::tsl_signer_anchors is a list; a TSLServiceCertChange the TSL announces is reported as the warning tsl_anchor_announced (shown by ti), and a release adds the CA to anchors::TSL_SIGNER_CAS_* |
gematik changes the TSL signer CA rarely (GEM.TSL-CA3 runs until 2028-05-25) and announces it weeks ahead; adding it to the list in a release is simple and auditable, while part E needs persisted announcements re-verified on load, a roots.json path with its own profile, age and OCSP checks, and signed test TSLs | an installation must follow an anchor change without a release |
| The TSL admits no CA on its own; of its per-CA metadata only the status, the certificate types and the OCSP services decide | the TSL is verified (spec/tsl-xmldsig), but a CA from it is kept only if a root from the A_28419 walk signed it. From a CA's entry, ti-pki uses its status as the CA's standing (TUC_PKI_018 step 5: in accord, or revoked with its StatusStartingTime, Tab_PKI_271: ca_revoked_in_tsl, issued_before_ca_revocation; VALID-008: no responder is asked about a CA) and the certificate types it may issue (TUC_PKI_007: cert_type_ca_not_authorized, also when the entry lists none), which makes a CA one for components, SMC-B, eGK or HBA; provider names and the other metadata decide nothing. For OCSP responders see the next row |
the TSL becomes a trust source of its own, admitting CAs no walked root signed |
OCSP responders beyond RFC 6960 are authorized by their TSL listing alone, with the warning ocsp_responder_not_rfc6960; a listed responder must still carry id-kp-OCSPSigning and be valid now |
gemSpec_PKI looks a responder certificate up among the TSL's OCSP services and nowhere else (TUC_PKI_006 step 5); the TSL binds a service to no CA (its supply point and extension are placeholders), and RFC 6960 option 2 is a duty of the TSPs for new responder certificates (A_23142-01). TI TSPs sign for their CAs with delegates of other CAs (ehca: a GEM.KOMP-CA51 delegate answers for GEM.SMCB-CA51), so the listing is accepted, but the deviation stays visible as a warning. Requiring validity now of a listed responder, although the TSL's status covers the signers of expired HBA/SMC-B CAs, is the maintainer's decision (TASK-270) | gematik aligns responder issuance with RFC 6960 (then the warning never shows) |
OCSP CertIDs use SHA-1 (sha1 crate) |
TI responders reject SHA-256 CertIDs as malformed (RFC 5019 profile). SHA-1 only names the certificate there; integrity rests on the response signature and the SHA-256 certHash | TI responders accept SHA-256 CertIDs |
ureq's unversioned transport API in ti-client (the Konnektor's transport and the profiles' agent), ureq pinned ~3.4 |
TlsConfig takes no certificate verifier, and a Konnektor needs Go's: .kon pins accepted by equality, CA chains checked for expectedHost, which is also the SNI (ti-tls's konnektor profile), as the ti and web profiles need theirs. A 60-line connector in ureq's chain does the TLS with that rustls config |
ureq accepts a custom verifier or rustls config |
The web TLS profile accepts an end-entity RSA key of 2048 to 2999 bits with the warning key_phased_out, and a server without a revocation status with revocation_unavailable (spec/pki-validation Deviations, TLSV-041, TLSV-044) |
gemSpec_Krypt A_28976 asks for RSA ≥ 3000 for internet identities and A_27857-02 for a revocation status; the Web-PKI's CAs issue RSA-2048 by default, Let's Encrypt ended OCSP, and responders fail now and then (idp-ref.app.ti-dienste.de staples nothing and lists no CRL, and GlobalSign's responder failed once). A revoked status still refuses; a verdict without a status is not reused. Maintainer's decision, TASK-282 |
the services the TI's clients reach staple OCSP or list CRLs and present RSA ≥ 3000 or ECDSA keys |
webpki-root-certs licence CDLA-Permissive-2.0 allowed for that crate only |
a permissive data licence for Mozilla's root certificates, which rustls-platform-verifier bundles for platforms without a usable OS store; only ti-client (and through it ti-cli) depends on it |
never, unless the licence policy changes |
Tests validate certificates generated by OpenSSL, not by ti-pki: an independent
implementation produces the bytes, so the encoder and the validator cannot share a
mistake. ti-pki/tests/pki/generate.sh (run with just test-pki) writes the topology of
gempki's internal/testca plus a few edge cases (a P-521 key, an RSA-PSS chain, a
looping and a misnamed cross certificate) as PEM files, with validity fixed around
2026-01-01 so they never go stale. Keys exist only while the script runs. The real TI
certificates in ti-pki/tests/fixtures/ and the Wycheproof vectors complement them.
The list is authenticated before it is read, by the detached signature file (.sig) and
the signer's status file (.ocsp) that the TSL service publishes next to it at the
automated download points of gemSpec_TSL A_21182 (spec/tsl-detached). The loaders take
the list from there only under the .sha2 of the HTTPS point, and from HTTPS otherwise; a
list without a fitting .sig is not used, and a set that does not fit is fetched once
more. A .ocsp older than two hours leaves the status to a network query, and --at never
uses it. .sig exists only on the plain-HTTP host, so a network that blocks it cannot take
new lists; the list in hand stays usable while HTTPS confirms it. ti pki tsl verify TSL SIG --signer-status FILE runs the check on files.
ti_pki::tsl reads gematik's ETSI TS 119 612 list with quick-xml and serde, matching
element names without their namespace prefix. It is used as a directory of candidate
intermediates, not as a trust source: match_to_roots keeps a CA only if a root of the
trust store issued and signed it, and the loading layer stores exactly those
(TrustStore::intermediates). What the list also says, and why it is not relied on, is in
Known compromises. The production list of September 2026 lists 90 CAs, of which 84 are
kept; the six others are self-signed legacy eGK CAs and two expired SMC-B CAs under the
retired GEM.RCA1. The tsl example prints this for any environment, and chain --tsl
builds chains from it.
ti_pki::ocsp builds requests and verifies responses in the core; OcspChecker (feature
load) posts them through the same Transport that fetches roots.json and the TSL.
The RFC 6960 structures are the crate's own der derives rather than x509-ocsp, whose
0.2 release sits on the previous RustCrypto stack: the signature must be checked over
the received bytes anyway, and the dozen structures are smaller than the glue between
two der versions. A response is accepted only with a matching CertID, an authorized
responder, a certHash over the certificate and within the TUC_PKI_006 window (37.5 s);
revocation::apply_revocation holds gempki's table of what each outcome means under
HardFail and SoftFail, with a revocation judged at the reference time (TUC_PKI_006 8a
and 8b). The test PKI script produces fixed-time responses (good, revoked,
unknown and the failure cases) signed by OpenSSL, and tests/fixtures holds two live
answers of the reference root responder. tests/real_world.rs checks a reference SMC-B
live, by its own responder.
Validator is the entry point: built from a TrustConfig and a TrustStore, it
validates an end entity through chain building, path validation, the gemSpec_Krypt key
check and OCSP for the end entity alone (TUC_PKI_018 step 7). A CA's standing is its
status in the verified TSL (step 5, VALID-008): in accord, or revoked after the end entity
was issued; under the Kompromissmodell this covers HBA and SMC-B CAs expired since. A profile
(ti_pki::profile) fills in a use case's end-entity requirements on top of the type
baseline; automatic selection matches a profile's role before the type's default and
reports ambiguity instead of guessing. A profile's revocation mode can tighten the
configured one but never loosen it, so production stays HardFail even for hci-aut,
whose Go default is SoftFail. trustdomain tells production from test certificates
offline, for picking a configuration. The validate example strings it all together.
ti-cli shows a list of objects as a table: aligned columns under UPPERCASE headings
on a terminal, a Markdown table when piped. A single object is label/value fields in
sections. Every view is written for its command; a generic JSON-to-text renderer was
tried and read worse.
Each table's columns are a decision, not a dump of the JSON:
- the focus column first: what the reader passes to the next command or looks for;
- then the columns that tell rows apart, then one or two details;
- everything else stays in
--format json; - nothing that does not decide trust:
pki tsl showshows only what the CA certificates say, never the TSL's own per-CA metadata (provider names, listed types, policies).
| Command | Focus | Further columns | Left to JSON |
|---|---|---|---|
pki profiles list |
PROFILE (the --profile value) |
TYPES, DEFAULT FOR, REVOCATION, ROLES, DESCRIPTION | role OIDs |
pki roots list |
ROOT, by generation | ORGANIZATION, KEY, VALIDITY, anchor mark | subject, SHA-256, PEM, walk order |
pki tsl show |
CA | ORGANIZATION (subject O=), ROOT (or the reason there is none), VALIDITY | policies, path length, TSL provider, PEM |
pki inspect --short |
SUBJECT (common name) | NOT AFTER (date), TELEMATIK-ID (only when a certificate has one; CAs never do) | everything else: JSON is the full report |
pki roots bundle, pki tsl bundle (with -o) |
CERTIFICATE | NOT AFTER, ALIAS (--p12) |
SHA-256, PEM |
connector config list |
* for the selected one, NAME (the -c value) |
URL, CONTEXT | path |
connector get cards |
HANDLE (what every card command takes, as is the ICCSN; shortened when longer than 20 characters, whole with -v) |
TYPE, ICCSN, TERMINAL (CtId and slot), HOLDER | versions, insert time |
connector get certificates |
REF | KEY, VALIDITY, ALGORITHM; SUBJECT and TELEMATIK-ID only where the certificates differ, otherwise once above the table | profession, dates, PEM |
connector get services |
SERVICE | VERSION, used mark, ENDPOINT | product |
connector get identities |
TELEMATIK-ID | TYPE, HOLDER, ICCSN, HANDLE (shortened as in get cards) |
|
connector get expiration |
VALID UNTIL | SUBJECT, ICCSN, SERIAL, HANDLE (shortened as in get cards) |
terminal |
connector get status, errors |
CONDITION | SEVERITY, TYPE, SINCE | inactive states |
probe |
SERVICE, status mark | HOST, TIME, DETAIL ({kind} … on success, the cause otherwise); Bestandsnetze not routed on one line |
kind, URL, source, HTTP status, answered |
A new list command adds its row here with its choice.
tests/openssl_cross.rs runs openssl verify and openssl ocsp next to ti-pki on the
same bytes and requires the same verdict: the test PKI's happy and failing chains, the
real reference SMC-B chain, every CA of the production TSL fixture, and the OCSP
fixtures. It skips when openssl or its brainpool curves are missing. just real-world
runs the ignored tests in tests/real_world.rs against gematik's endpoints: roots.json
and TSL of every environment through HttpLoader and Reloader, a reference SMC-B end
to end with OCSP for the end entity. The deliberate
differences to gematik's reference implementation are in
gemlibpki-comparison.md.
The suites that need a live system, which just check and CI never run. Before a crate
is released, every suite listed for it runs green and the release PR records the runs
(environment, Konnektor, card, commit); the rules are in
testing.
| Suite | Run with | Needs | Proves | Required for |
|---|---|---|---|---|
ti-pki real world (tests/real_world.rs, #[ignore]) |
just real-world |
network, curl |
roots.json and TSL of every environment through HttpLoader and Reloader, a reference SMC-B end to end with OCSP for the end entity, its responder listed in the TSL |
ti-pki, ti-cli |
ti-pki OpenSSL parity (tests/openssl_cross.rs) |
just test |
openssl with brainpool curves |
the same verdict as openssl verify / openssl ocsp on the same bytes |
ti-pki |
Konnektor end to end (ti-connector-client/tests/e2e.rs) |
just konnektor-e2e, just konnektor-e2e-interactive (PIN entry, HITL) |
TI_TEST_KON_PATH (a .kon), optional TI_TEST_PDFA_PATH (interactive), TI_TEST_KON_RECORD_DIR (records the run for tests/recorded.rs) |
the facades against a real Konnektor and card | ti-connector-client, ti-cli |
probe through the TI VPN (ti-cli/tests/probe.rs, #[ignore], HITL) |
TI_TEST_PROBE_ENV=ref TI_TEST_PROBE_VPN=on cargo test -p ti-cli --test probe -- --ignored --exact hitl_the_ti_vpn_block_answers_exactly_with_the_vpn --nocapture, then with TI_TEST_PROBE_VPN=off |
a Konnektor or TI-Gateway with its TI VPN; network | the TI-VPN block of probe answers exactly when the VPN is up, the internet block either way |
ti-cli |
| jwz in the browser | just jwz-browser (design time, stamped) |
Chrome for Testing, wasm-bindgen-test-runner |
jwz and ti-jwz's brainpool in wasm32 in a real browser |
jwz, ti-jwz (the stamp is checked by just check) |
ti-wasm exposes ti-pki's offline and model to JavaScript: verify_tsl gives the TSL
view (signature verdict, roots, services with chains, certificate details),
describe_certificate what ti pki inspect shows. Its exports are pure; downloads, caching and the clock belong to the
caller, since the TI download hosts send no CORS headers and the OCSP responders speak
plain HTTP. One module serves Node (initSync with the bytes) and the browser (init
with a URL).
#[wasm_bindgen] expands to unsafe glue, so ti-wasm has its own lint table without
unsafe_code = "forbid"; its sources are thin wrappers, just wasm-unsafe-free keeps
unsafe out of them, and all logic stays in ti-pki under the workspace lints.
The wasm profile is release (opt-level 3) and wasm-build runs wasm-opt -O3:
verification speed before size. wasm-build needs wasm-bindgen-cli at the version
ti-wasm/Cargo.toml pins (just tools) and binaryen's wasm-opt (brew install binaryen); without it the build works but wasm-vendor refuses the result. A consumer
gets the package with just wasm-vendor <dir> into <dir>/vendor/ti-wasm, after the smoke
test and the size budget pass, from a clean tree only; VERSION.json names the commit and
the module's SHA-256.
roots.json and the TSL are loaded, cached and hot-reloaded by ti_pki::load (feature
load). Loaders are untrusted: whatever the source — HTTP, a mounted file, an offline
bundle, a cache — roots.json is verified against the embedded anchor before use, and only
the TSL's CAs that a verified root signed are kept. A misbehaving source can therefore only
deny service or serve stale data, and the freshness policy catches staleness. The module
docs carry the composition and the failure table.
Over HTTP the TSL is not downloaded while the SHA-256 published next to it (.sha2)
matches the list in hand; a download must match that hash (A_30044 (2) of C_12791). The
hash only saves the download: the list is verified as before, whatever it says.
The core has no HTTP client and no executor: the host implements load::Transport and
calls Reloader::tick on its own runtime. just core-deps proves the crate stays free
of them, and just wasm32 that the loading layer builds for the browser. A Rust server
brings its adapters in its own crate.
- Periodic with jitter (default):
ReloadPolicy::intervalplus a per-process random share ofjitter, so a fleet started together does not reload in lockstep. - TSL
NextUpdatedeadline: withhonor_tsl_next_update, the next reload is duenext_update_leadbefore the current TSL'sNextUpdateif that comes first. - External trigger: the host calls
Reloader::tickon SIGHUP or from an admin endpoint, e.g. right after a ConfigMap update. - Lazy on use — rejected: reloading from
snapshot()would put network I/O, verification latency and their failures into the request path, need an executor there, and stampede the origin when many requests notice staleness at once.snapshot()stays a short read that only refuses expired material.
Production caps hard_expiry at MAX_PROD_HARD_EXPIRY (24 h); ReloadPolicy::validate
and Reloader::new enforce it.
- A ConfigMap volume updates by atomically repointing its
..datasymlink.FileTransportreads through the configured path on every call, so it follows the swap; never cache a canonicalised path or an open file. subPathmounts never receive updates; mount the whole volume.- Updates arrive after the kubelet sync period plus its cache TTL (about a minute by default), then on the next reload tick — or trigger one.
- Put roots.json, the TSL and its
.sigand.ocspin one ConfigMap so they change together; a list under the signature of another state does not verify. The producer,ti pki trust export, writes them as one bundle file; consumers keep nothing on disk. - Poll, don't watch: inotify is unreliable on these mounts because the files themselves never change, only the symlink.
For air-gapped deployments, export both artefacts with their metadata on a connected
machine (ti pki trust export, or Bundle::new(&config, label, material).write(path) from
material any loader produced), transfer the JSON file, and serve it with
StaticLoader::from_bundle(Bundle::read(path)?, &config)?, alone or as the backup of a
FallbackLoader. The bundle is not signed and needs no signature: it is verified on
import like any other source, and its anchor hash rejects a bundle meant for another
environment up front. It carries fetched_at, so it ages out by hard_expiry like any
material — in production that means a fresh bundle at least daily.
A crate consumed by git tag or from a registry is built outside this workspace, so its
in-repo dependencies resolve by the version recorded in its Cargo.toml, not by path. To
make a release pick up a sibling's change, that sibling must be released first and the
dependent's version requirement raised.
# 1. Land the change on main (PR, review, merge)
# 2. Bump the changed crates: `version` in <crate>/Cargo.toml, its line in
# [workspace.dependencies] when siblings need the new version, and in ReleaseNotes.md
# the crate's block under "Unreleased" becomes "## Release <crate> X.Y.Z, <date>"
just changed # what changed since each crate's last tag
git add -A && git commit -m "ti-pki: release 0.1.1"
# A crate with a threshold (mutation-scores.toml: the crates that make a security decision)
# needs a score that belongs to its code as released and reaches the threshold; `just release`
# checks it with `just mutants-gate`, which passes a crate without a threshold. Code is what
# a test compiles or runs: a change to the README, the licence, a document no
# include_str! compiles in, the stamps, the fuzz targets or the [package] version line
# needs no new score (`python3 -I scripts/mutation.py affecting <crate> <rev>` lists the rest)
just mutants-record ti-pki # slow; commit mutation-scores.toml
# 3. Tier 2 must be green before tagging (semver compares against the last tag)
just audit
# and every gated suite listed for the crates being released (see "Gated tests"),
# recorded in the release PR
# 4. Tag the crates that changed, then push the tags
just tag ti-pki 0.1.1
just push-tagsOnly tag what actually changed — just changed lists crates with commits since their last
tag. Unchanged crates keep their existing tag. Check what a release would contain with
just package-list <crate>: only the crate's Cargo.toml, README.md, LICENSE and
src/ may appear. The release notes of all crates are one file, ReleaseNotes.md.
Consumption goes through three stages as the distribution matures:
# 1. git, pinned to the crate's own tag (Cargo finds the crate by name in the workspace)
ti-pki = { git = "https://github.com/gematik/zero-lab", tag = "rust/ti-pki/v0.1.0" }
# 2. internal registry (once configured)
ti-pki = { version = "0.1", registry = "gematik" }
# 3. crates.io
ti-pki = "0.1"A git dependency is exact: the tag names one commit, and the consumer's Cargo.lock
records its hash. [workspace.package] sets publish = false for every crate until the
registry decision is made; flipping it there opens them all.
release-plz.toml is prepared (tag format rust/<crate>/v<ver>) but
not yet usable: release-plz derives the next version from the registry, which it skips for
publish = false crates, and its git_only mode (as of 0.3.169) fails for a workspace that
is not at the repository root. Until a registry is in place, bumps are done by hand as
above; afterwards release-plz update does the version and sibling-pin edits (it writes only
CHANGELOG.md, so its changelog updates stay off; release notes are ReleaseNotes.md).
Crates are released together or alone, each with its own version and tag; ti is the
one crate with binaries. A release takes two commands from main:
- Gated tests first. Run every suite of "Gated tests" listed for the crates in the
release against the real systems and record the results in the release PR;
just releasecannot do this for you. just release CRATE=X.Y.Z …, local only, e.g.just release ti-cli=0.4.0 ti-idpd-client=0.2.0 ti-directory-client=0.1.0. It stops unlessmainis clean and equal toorigin/main, each tag is new and above the crate's lastrust/<crate>/v*, andReleaseNotes.mdhas exactly one### <crate>block under## Unreleasedfor each. Thenjust check-fullandjust advisories vet msrv, unless no released crate's code or the workspace'sCargo.tomlandCargo.lockchanged since its last tag (a docs-only release, such as a README fix crates.io only shows with a new version): that releases the same code, so onlyjust advisories vetrun; the mutation gate andjust semverrun either way. Per crate the version in itsCargo.toml, the workspace requirement on it inCargo.toml("0.2"for 0.x, the major from 1.0) and its block becoming## Release <crate> X.Y.Z, YYYY-MM-DD;Cargo.lock;just semverfor the released crates at their new versions;just jwz-recheck, becauseCargo.tomlchanged (only the browser stamp hashes a manifest, so a bump reruns that one, a few minutes); whenti-cliis in it,just cli-targetsbuilds the three binaries intotarget/distwith theirSHA256SUMS. One commitrelease: <crate> X.Y.Z, …, one tagrust/<crate>/vX.Y.Zper crate. A failure or Ctrl-C after the gate undoes the bumps.just publish X.Y.Z [OWNER](theti-cliversion):git push --atomic origin mainwith every crate tag of the release commit,gh release createwith the binaries, the checksums and that version's notes (plus install and verify lines), thenjust publish-brew OWNER X.Y.Z.OWNERdefaults toTAP_OWNERin the Justfile (gematik). A release withoutti-cliis pushed as the recipe prints it.
just audit's semver step compares each tagged crate's public API with its last tag
(0.x: a breaking change needs the next minor). It skips ti-cli, a binary whose contract
is its JSON schemas, and crates never tagged.
To drop a release that is not published yet: git tag -d <its tags> && git reset --keep HEAD~1 (the recipe prints the line).
publish-brew takes the checksums from the published release, fills
ti-cli/Formula/ti.rb with each platform's asset URL and hash, and pushes it to
OWNER/homebrew-tap. The formula installs the release binaries; nothing is compiled on
the user's machine. The checksums are not signed yet (see "Known compromises").
- Develop against the workspace — local, fast, no tags, no version edits.
- Release = land the change → bump version, sibling pin and release notes →
just audit→just tag <crate> <version>for each changed crate →just push-tags. Only then do git-tag consumers see the new code, because those builds bypass the workspace paths.
| Recipe | Purpose |
|---|---|
tools |
Install the pinned cargo tools (via cargo-binstall when available) |
install |
Install ti into ~/.cargo/bin, built like a release |
install-fast |
Install ti from the local profile (thin LTO, incremental): a change in ti-cli rebuilds in about 20 s instead of about 45 s |
check-full |
Tier 1 before a release (just release runs it): check, test-defaults, features, cli-check |
cli-check |
cli-targets check: clippy with -D warnings for the Linux (musl) and Windows targets of ti in their cross containers; needs Docker |
check-changed [base] |
While working on a branch: the fast checks of the crates it changed against base (default main) and of their dependents; wasm32, nonprod-absent, the manifest checks (unsafe-free, core-deps, jwz-backends, machete, deny, vet) and scripts-test only when their inputs changed |
check |
Tier 1, once per branch before it merges: fmt, clippy, doc, test, wasm32, wasm-unsafe-free, core-deps, nonprod-absent, jwz-backends, jwz-rsa-guard, machete, deny, vet, brainpool-absent for jwz and ti-jwz |
wasm32 |
cargo check for wasm32 of ti-types with std, ti-pki's loading layer, ti-wasm and the jwz crates |
wasm-unsafe-free |
No unsafe in ti-wasm's own sources |
check-jwz |
The jwz crates only (fmt, clippy, doc, test, feature matrix, wasm32, guards): the loop while working on jwz |
jwz-backends |
No openssl, ring, aws-lc or cryptoki under jwz or ti-jwz |
jwz-rsa-guard |
jwz's reserved rsa feature still refuses to build |
brainpool-absent <crate> |
No brainpool in a crate's tree (run for every new component) |
verify-formal-jwz |
Design time (Kani): the bounded proofs in jwz/src/proofs.rs |
verify-hax-jwz |
Design time (hax, F*): extracts jwz's Concat KDF core and proves it against SP 800-56A §5.8.1 (jwz/verification/hax) |
jwz-recheck [--all] |
Reruns the design-time checks (Kani, hax and F*, browser) whose inputs changed and rewrites jwz/verification/stamps; just check fails on a stale stamp without needing the tools |
jwz-browser |
Design time (Chrome for Testing): the browser tests of jwz and ti-jwz's brainpool, wasm32 in headless Chrome |
jwz-browser-tools |
Download Chrome for Testing's headless shell and chromedriver into JWZ_CHROME_DIR (default ~/Development/tools/chrome) |
jwz-interop |
Design time (Go, Python 3): brainpool interop fixtures against Go josebp and Python jwcrypto, both directions, and their coverage table (ti-jwz/interop) |
fuzz-jwz [seconds] |
Design time (nightly, cargo-fuzz): every jwz parser, seconds each (default 30), from the committed seeds |
fuzz-ti-cache [seconds] |
Design time (nightly, cargo-fuzz): ti-cache's Cache-Control parser, seconds (default 30), from the committed seeds |
tls-bench |
Part of check-full: a cold and a cached TLS server verification per profile of spec/pki-validation, release build; fails when a median exceeds its budget (5 ms cold, 10 µs cached) |
fuzz-ti-tls [seconds] |
Design time (nightly, cargo-fuzz): the chain a server presents to ti-tls's web profile, seconds (default 30), from the committed seed |
fuzz-ti-pki [seconds] |
Design time (nightly, cargo-fuzz): ti-pki's parsers of network bytes (the detached TSL signature, the TSL list, OCSP responses), seconds each, from the committed seeds |
fuzz-ti-connector-client [seconds] |
Design time (nightly, cargo-fuzz): the .kon parser, its file check and redacted view, seconds each (default 30) |
fuzz-ti-connector-client [seconds] |
Design time (nightly, cargo-fuzz): the .kon parser with its file check and redacted view, the service directory with its bindings and rewrite, and every Konnektor call on any answer, seeded from the recorded runs, seconds each (default 30) |
fuzz-ti-pkcs12 [seconds] |
Design time (nightly, cargo-fuzz, Go): the PKCS#12 decoder with its password, seeded from a fresh spec/pkcs12 conformance corpus, seconds (default 30) |
wasm-build |
Build the ti-wasm package into target/ti-wasm/pkg |
wasm-size |
Module size raw, gzip, brotli; fails over 2.0 MB raw or 700 KB gzip |
wasm-smoke |
The package in Node on the real TSLs, cross-checked with ti pki tsl verify |
wasm-vendor [<dir>] |
Copy the package into <dir>/vendor/ti-wasm (default ../../gemiverse) |
core-deps |
Prove ti-pki's core pulls in no HTTP client, executor or file watcher |
test-pki |
Regenerate the OpenSSL test PKI in ti-pki/tests/pki |
nonprod-absent |
Prove ti-pki without dangerous-nonprod contains no non-prod trust material |
audit |
Tier 2: advisories, vet, msrv, semver |
vet-suggest |
Dependencies still awaiting a cargo vet audit |
miri, mutants <crate> [jobs] |
On demand: Miri (nightly), mutation testing (jobs as for mutants-record) |
mutants-record <crate> [jobs] |
Measure a crate's mutation score on the committed tree into mutation-scores.toml; jobs mutants at once (default 4), each in a copy of rust/ beside a link to spec/; 1 mutates the tree in place |
mutants-gate <crate>… |
The recorded scores of crates about to be released that have a threshold: present, current, at least the threshold; a crate without one is not gated |
coverage [<crate>…] |
Line coverage per crate (HTML in target/coverage/html) and the cited requirements whose code no test runs |
versions |
Show the latest tag for every crate |
changed |
List crates with commits since their last tag |
tag <crate> <ver> |
Create the rust/<crate>/v<ver> release tag |
push-tags |
Push all local tags to origin |
package-list <crate> |
Show the files a release of the crate would contain |
release <crate=ver>… |
Prepare a release of one or more crates locally (see "Releasing") |
release-notes [<ver> [<crate>]] |
A crate's notes from ReleaseNotes.md (default: ti-cli's pending ones) |
publish <ver> [<tap owner>] |
Push and publish a prepared ti release |
publish-brew <owner> <ver> |
Point the formula in <owner>/homebrew-tap at a ti release |