git-ents.gitmain
⌘K
foforge
commit 1003cda
multi: add ents-testutil, the shared dev-only fixture harness

The integration harness the engineering conventions call for, landed now because phase 3’s two crates both need it: an in-memory RefStoreRead (MemRefStore), the facet-git-tree in-memory ObjectStore re-exported as the object side, deterministic ed25519 keys (Keypair), a signed-commit builder that signs the gpgsig-less serialization in git’s SSHSIG namespace exactly like git commit -S, seeding helpers for members/results/meta entities/code-ref chains, and a CountingFind wrapper for asserting incremental-evaluation read bounds.

Adds the ssh-key workspace dependency (pure-Rust SSHSIG) rather than shelling out to ssh-keygen as the pre-redo tree did.

Assisted-by: Claude:claude-fable-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

Cargo.lock @@ -38,6 +38,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -109,6 +121,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clru" version = "0.6.3" @@ -124,6 +146,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32b13ea120a812beba79e34316b3942a857c86ec1593cb34f27bb28272ce2cca" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -157,6 +185,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -167,6 +207,32 @@ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dashmap" version = "6.2.1" @@ -212,6 +278,16 @@ "thiserror", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -219,7 +295,9 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -228,6 +306,60 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2", + "subtle", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -251,6 +383,21 @@ "thiserror", ] +[[package]] +name = "ents-testutil" +version = "0.0.0" +dependencies = [ + "ents-model", + "facet", + "facet-git-tree", + "gix", + "gix-hash", + "gix-object", + "gix-ref-store", + "rstest", + "ssh-key", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -385,6 +532,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filetime" version = "0.2.29" @@ -457,6 +620,18 @@ dependencies = [ "typenum", "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", ] [[package]] @@ -1393,6 +1568,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "hash32" version = "0.3.1" @@ -1440,6 +1626,15 @@ "stable_deref_trait", ] +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "human_format" version = "1.2.1" @@ -1474,6 +1669,15 @@ "hashbrown 0.17.1", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "io-close" version = "0.3.7" @@ -1541,12 +1745,27 @@ "static_assertions", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1606,6 +1825,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec 1.15.2", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1613,6 +1867,7 @@ checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1621,6 +1876,44 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1644,6 +1937,15 @@ "windows-link", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1656,6 +1958,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1680,6 +2003,15 @@ "zerocopy", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -1719,8 +2051,8 @@ "bit-vec", "bitflags 2.13.0", "num-traits", - "rand", - "rand_chacha", + "rand 0.9.4", + "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -1755,14 +2087,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -1772,7 +2124,16 @@ checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1790,7 +2151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -1837,6 +2198,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rstest" version = "0.26.1" @@ -1927,6 +2319,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "semver" version = "1.0.28" @@ -1983,6 +2389,17 @@ "sha1", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shell-words" version = "1.1.1" @@ -2009,6 +2426,16 @@ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "slab" version = "0.4.12" @@ -2027,6 +2454,64 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef784004ca8777809dcdad6ac37629f0a97caee4c685fcea805278d81dd8b857" +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "cipher", + "ssh-encoding", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "ed25519-dalek", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa", + "sec1", + "sha2", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2045,6 +2530,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.118" @@ -2212,6 +2703,12 @@ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -2302,6 +2799,12 @@ "syn", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zlib-rs" version = "0.6.6"
Cargo.toml @@ -1,6 +1,10 @@ [workspace] resolver = "3" -members = ["crates/ents-model", "crates/gix-ref-store"] +members = [ + "crates/ents-model", + "crates/ents-testutil", + "crates/gix-ref-store", +] [workspace.package] edition = "2024" @@ -12,6 +16,9 @@ missing_docs = "warn" [workspace.dependencies] +ents-model = { path = "crates/ents-model" } +ents-testutil = { path = "crates/ents-testutil" } +gix-ref-store = { path = "crates/gix-ref-store" } arborium = { version = "2.18", default-features = false, features = [ "lang-rust", "lang-toml", @@ -63,6 +70,7 @@ rstest = "0.26" semver = "1" spdx = "0.13" +ssh-key = { version = "0.6", features = ["ed25519"] } target-lexicon = "0.13" tempfile = "3" thiserror = "2"
crates/ents-testutil/Cargo.toml @@ -1,0 +1,22 @@ +[package] +name = "ents-testutil" +version = "0.0.0" +edition.workspace = true +publish.workspace = true +license.workspace = true + +[dependencies] +ents-model = { workspace = true } +facet = { workspace = true } +facet-git-tree = { workspace = true } +gix = { workspace = true } +gix-hash = { workspace = true } +gix-object = { workspace = true } +gix-ref-store = { workspace = true } +ssh-key = { workspace = true } + +[dev-dependencies] +rstest = { workspace = true } + +[lints] +workspace = true
crates/ents-testutil/src/commit.rs @@ -1,0 +1,107 @@ +//! A commit builder that signs the way git's SSH signing does. + +use gix_hash::ObjectId; +use gix_object::{Commit, Kind, Write, WriteTo as _}; + +use crate::keys::Keypair; + +/// The inputs for one fixture commit; see [`write_commit`]. +/// +/// Author and committer are fixed fixture identities — only the timestamp +/// varies, because timestamps are what revocation-boundary logic keys on. +/// +/// # Examples +/// +/// ``` +/// use ents_testutil::{CommitSpec, ObjectStore, empty_tree, write_commit}; +/// +/// let objects = ObjectStore::default(); +/// let tree = empty_tree(&objects); +/// let spec = CommitSpec { +/// tree, +/// parents: vec![], +/// message: "Initial".into(), +/// seconds: 1_000, +/// }; +/// let oid = write_commit(&objects, &spec, None); +/// assert!(objects.get(&oid).is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct CommitSpec { + /// The tree this commit records. + pub tree: ObjectId, + /// Parent commits, in order; empty for a root commit. + pub parents: Vec<ObjectId>, + /// The full commit message, trailers included. + pub message: String, + /// Author and committer timestamp, in seconds since the Unix epoch. + pub seconds: i64, +} + +fn actor(seconds: i64) -> gix::actor::Signature { + gix::actor::Signature { + name: "Fixture".into(), + email: "fixture@ents.test".into(), + time: gix::date::Time { seconds, offset: 0 }, + } +} + +/// Write the commit described by `spec` into `objects`, signing it with +/// `key` when one is given. +/// +/// Signing works exactly the way `git commit -S` with an SSH key does: the +/// SSHSIG (namespace `git`) is computed over the commit object serialized +/// *without* its `gpgsig` header, then stored as that header's value — so +/// the signature replicates with the repository and verifies offline in +/// every clone. +/// +/// # Examples +/// +/// ``` +/// use ents_testutil::{CommitSpec, Keypair, ObjectStore, empty_tree, write_commit}; +/// +/// let objects = ObjectStore::default(); +/// let key = Keypair::from_seed(1); +/// let spec = CommitSpec { +/// tree: empty_tree(&objects), +/// parents: vec![], +/// message: "Signed".into(), +/// seconds: 1_000, +/// }; +/// let oid = write_commit(&objects, &spec, Some(&key)); +/// +/// // The stored bytes carry the signature as repository data. +/// let raw = objects.get(&oid).expect("stored"); +/// # let gix_object::Object::Commit(commit) = raw else { panic!("not a commit") }; +/// assert!(commit.extra_headers.iter().any(|(k, _)| k == "gpgsig")); +/// ``` +pub fn write_commit(objects: &impl Write, spec: &CommitSpec, key: Option<&Keypair>) -> ObjectId { + let mut commit = Commit { + tree: spec.tree, + parents: spec.parents.clone().into(), + author: actor(spec.seconds), + committer: actor(spec.seconds), + encoding: None, + message: spec.message.clone().into(), + extra_headers: Vec::new(), + }; + + if let Some(key) = key { + let mut payload = Vec::new(); + commit + .write_to(&mut payload) + .expect("serializing a commit to a Vec cannot fail"); + let pem = key.sign(&payload); + commit + .extra_headers + .push(("gpgsig".into(), pem.trim_end().into())); + } + + let mut raw = Vec::new(); + commit + .write_to(&mut raw) + .expect("serializing a commit to a Vec cannot fail"); + objects + .write_buf(Kind::Commit, &raw) + .expect("in-memory object write cannot fail") +}
crates/ents-testutil/src/counting.rs @@ -1,0 +1,69 @@ +//! An object-read counter, for asserting incremental-evaluation bounds. + +use std::cell::Cell; + +use gix_object::Find; +use gix_object::find; + +/// Wraps any [`Find`] and counts `try_find` calls, so a test can assert +/// that an "incremental" code path really is bounded — for example, that +/// evaluating a query entry set after a one-commit advance does not +/// re-walk a three-hundred-commit history (`query.incremental`). +/// +/// # Examples +/// +/// ``` +/// use ents_testutil::{CountingFind, ObjectStore, empty_tree}; +/// use gix_object::Find as _; +/// +/// let objects = ObjectStore::default(); +/// let tree = empty_tree(&objects); +/// +/// let counting = CountingFind::new(&objects); +/// let mut buf = Vec::new(); +/// counting.try_find(&tree, &mut buf).expect("readable"); +/// assert_eq!(counting.reads(), 1); +/// +/// counting.reset(); +/// assert_eq!(counting.reads(), 0); +/// ``` +#[derive(Debug)] +pub struct CountingFind<'a, F: Find> { + inner: &'a F, + reads: Cell<usize>, +} + +impl<'a, F: Find> CountingFind<'a, F> { + /// Wrap `inner`, starting the counter at zero. + #[must_use] + pub fn new(inner: &'a F) -> Self { + Self { + inner, + reads: Cell::new(0), + } + } + + /// How many `try_find` calls have happened since the last + /// [`CountingFind::reset`]. + #[must_use] + pub fn reads(&self) -> usize { + self.reads.get() + } + + /// Zero the counter — typically after warming caches, so an assertion + /// covers only the increment under test. + pub fn reset(&self) { + self.reads.set(0); + } +} + +impl<F: Find> Find for CountingFind<'_, F> { + fn try_find<'b>( + &self, + id: &gix_hash::oid, + buffer: &'b mut Vec<u8>, + ) -> Result<Option<gix_object::Data<'b>>, find::Error> { + self.reads.set(self.reads.get().saturating_add(1)); + self.inner.try_find(id, buffer) + } +}
crates/ents-testutil/src/keys.rs @@ -1,0 +1,79 @@ +//! Deterministic SSH signing keys for fixtures. + +use ssh_key::private::{Ed25519Keypair, KeypairData}; +use ssh_key::{HashAlg, LineEnding, PrivateKey}; + +/// The SSHSIG namespace git uses when signing commits with an SSH key. +pub const GIT_SIGN_NAMESPACE: &str = "git"; + +/// A deterministic ed25519 keypair for signing fixture commits. +/// +/// Deterministic (seeded) rather than random so property tests and +/// build-the-fixture-twice "clone" tests reproduce byte-identical objects. +/// +/// # Examples +/// +/// ``` +/// use ents_testutil::Keypair; +/// +/// let a = Keypair::from_seed(1); +/// let b = Keypair::from_seed(1); +/// assert_eq!(a.public_openssh(), b.public_openssh()); +/// assert_ne!(a.public_openssh(), Keypair::from_seed(2).public_openssh()); +/// ``` +#[derive(Debug)] +pub struct Keypair { + private: PrivateKey, +} + +impl Keypair { + /// Derive a keypair from a one-byte seed (repeated to fill the ed25519 + /// seed), so tests can name keys `1`, `2`, ... and always get the same + /// key back. + #[must_use] + pub fn from_seed(seed: u8) -> Self { + let pair = Ed25519Keypair::from_seed(&[seed; 32]); + let private = PrivateKey::new(KeypairData::from(pair), "ents-testutil") + .expect("a well-formed ed25519 keypair is always accepted"); + Self { private } + } + + /// The public half in OpenSSH single-line format — exactly what a + /// [`ents_model::Member`]'s `key` field carries. + /// + /// # Examples + /// + /// ``` + /// use ents_testutil::Keypair; + /// + /// let key = Keypair::from_seed(1).public_openssh(); + /// assert!(key.starts_with("ssh-ed25519 ")); + /// ``` + #[must_use] + pub fn public_openssh(&self) -> String { + self.private + .public_key() + .to_openssh() + .expect("an ed25519 public key always renders") + } + + /// Sign `payload` in git's SSHSIG namespace, returning the armored + /// signature block git would store in a commit's `gpgsig` header. + /// + /// # Examples + /// + /// ``` + /// use ents_testutil::Keypair; + /// + /// let pem = Keypair::from_seed(1).sign(b"payload"); + /// assert!(pem.starts_with("-----BEGIN SSH SIGNATURE-----")); + /// ``` + #[must_use] + pub fn sign(&self, payload: &[u8]) -> String { + self.private + .sign(GIT_SIGN_NAMESPACE, HashAlg::Sha512, payload) + .expect("ed25519 signing is infallible for any payload") + .to_pem(LineEnding::LF) + .expect("an SSHSIG always renders as PEM") + } +}
crates/ents-testutil/src/lib.rs @@ -1,0 +1,66 @@ +//! Shared test fixtures for `git-ents` crates: an in-memory ref store, an +//! in-memory object store, deterministic signing keys, a signed-commit +//! builder, and seeding helpers for members, results, and code-ref history. +//! +//! This is the integration harness the engineering conventions call for — +//! a dev-dependency-only crate, never linked into a shipping binary. It +//! exists so `ents-gate` and `ents-query` (and later `ents-receive`, +//! `ents-sync`, `ents-effect`) exercise the same fixture vocabulary instead +//! of each reinventing repos, keys, and commit plumbing. +//! +//! Everything here panics on setup failure (via `expect`) rather than +//! returning `Result`: a fixture that cannot be built is a broken test, +//! not a condition under test. +//! +//! # Examples +//! +//! A complete miniature forge: one member enrolled under +//! `refs/meta/member/*`, one signed mutation, both readable back through +//! the same read-half trait production code uses. +//! +//! ``` +//! use ents_model::Provenance; +//! use ents_testutil::{Keypair, MemRefStore, ObjectStore, enroll_member}; +//! use gix_ref_store::RefStoreRead; +//! +//! let refs = MemRefStore::default(); +//! let objects = ObjectStore::default(); +//! let key = Keypair::from_seed(1); +//! +//! let tip = enroll_member(&refs, &objects, "jdc", &key, Provenance::AdminRegistered, 1_000); +//! +//! let name: gix::refs::FullName = "refs/meta/member/jdc".try_into().expect("valid"); +//! assert_eq!(refs.get(name.as_ref()).expect("readable"), Some(tip)); +//! ``` + +#![expect( + clippy::expect_used, + reason = "test-support crate: fixtures panic on setup failure rather than returning Result" +)] + +pub use facet_git_tree::ObjectStore; + +mod commit; +mod counting; +mod keys; +mod refs; +mod seed; + +pub use commit::{CommitSpec, write_commit}; +pub use counting::CountingFind; +pub use keys::Keypair; +pub use refs::MemRefStore; +pub use seed::{ + advance_ref, empty_tree, enroll_member, record_result, write_member, write_meta_entity, +}; + +/// Read a ref from a fixture store, panicking on the (impossible for +/// [`MemRefStore`]) backend error. +pub(crate) fn refs_get( + refs: &MemRefStore, + name: &gix::refs::FullName, +) -> Option<gix_hash::ObjectId> { + use gix_ref_store::RefStoreRead as _; + refs.get(name.as_ref()) + .expect("in-memory ref read cannot fail") +}
crates/ents-testutil/src/refs.rs @@ -1,0 +1,122 @@ +//! An in-memory [`RefStoreRead`] implementation for fixtures. + +use std::collections::BTreeMap; +use std::sync::Mutex; + +use gix::refs::{FullName, FullNameRef}; +use gix_hash::ObjectId; +use gix_ref_store::{RefIter, RefStoreRead, Result}; + +/// An in-memory ref store: a name-to-oid map behind the same +/// [`RefStoreRead`] trait production code consumes. +/// +/// Mutation happens through [`MemRefStore::set`] and +/// [`MemRefStore::remove`] — deliberately *not* through the `RefStore` +/// write half, because fixtures seed state directly; the CAS discipline +/// itself is `gix-ref-store`'s to test. +/// +/// # Examples +/// +/// ``` +/// use ents_testutil::MemRefStore; +/// use gix_ref_store::RefStoreRead; +/// +/// let store = MemRefStore::default(); +/// let name: gix::refs::FullName = "refs/heads/main".try_into().expect("valid"); +/// let oid = gix_hash::ObjectId::null(gix_hash::Kind::Sha1); +/// +/// store.set(name.as_ref(), oid); +/// assert_eq!(store.get(name.as_ref()).expect("readable"), Some(oid)); +/// +/// store.remove(name.as_ref()); +/// assert_eq!(store.get(name.as_ref()).expect("readable"), None); +/// ``` +#[derive(Debug, Default)] +pub struct MemRefStore { + refs: Mutex<BTreeMap<String, ObjectId>>, +} + +impl MemRefStore { + /// Set `name` to `oid`, creating or overwriting it. + pub fn set(&self, name: &FullNameRef, oid: ObjectId) { + self.locked().insert(name.as_bstr().to_string(), oid); + } + + /// Set the ref named by `name` (a full refname string) to `oid`. + /// + /// Panics if `name` is not a valid full refname. + /// + /// # Examples + /// + /// ``` + /// use ents_testutil::MemRefStore; + /// + /// let store = MemRefStore::default(); + /// store.set_str("refs/heads/main", gix_hash::ObjectId::null(gix_hash::Kind::Sha1)); + /// ``` + pub fn set_str(&self, name: &str, oid: ObjectId) { + let name: FullName = name.try_into().expect("valid refname in fixture"); + self.set(name.as_ref(), oid); + } + + /// Delete `name` if present. + pub fn remove(&self, name: &FullNameRef) { + let key = name.as_bstr().to_string(); + let _removed = self.locked().remove(&key); + } + + /// A deep copy of this store's current refs — the fixture analogue of + /// a fetch, for pre-flight call-site tests that evaluate against a + /// clone's ref state. + /// + /// # Examples + /// + /// ``` + /// use ents_testutil::MemRefStore; + /// use gix_ref_store::RefStoreRead; + /// + /// let store = MemRefStore::default(); + /// store.set_str("refs/heads/main", gix_hash::ObjectId::null(gix_hash::Kind::Sha1)); + /// + /// let fetched = store.fetched_copy(); + /// let name: gix::refs::FullName = "refs/heads/main".try_into().expect("valid"); + /// assert_eq!( + /// fetched.get(name.as_ref()).expect("readable"), + /// store.get(name.as_ref()).expect("readable"), + /// ); + /// ``` + #[must_use] + pub fn fetched_copy(&self) -> Self { + Self { + refs: Mutex::new(self.locked().clone()), + } + } + + fn locked(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, ObjectId>> { + self.refs + .lock() + .expect("ref-store mutex poisoned in fixture") + } +} + +impl RefStoreRead for MemRefStore { + fn get(&self, name: &FullNameRef) -> Result<Option<ObjectId>> { + Ok(self.locked().get(&name.as_bstr().to_string()).copied()) + } + + fn iter_prefix(&self, prefix: &str) -> Result<RefIter> { + let snapshot: Vec<_> = self + .locked() + .range(prefix.to_owned()..) + .take_while(|(name, _)| name.starts_with(prefix)) + .map(|(name, oid)| { + let full: FullName = name + .as_str() + .try_into() + .expect("only valid refnames are ever inserted"); + Ok((full, *oid)) + }) + .collect(); + Ok(RefIter::new(snapshot.into_iter())) + } +}
crates/ents-testutil/src/seed.rs @@ -1,0 +1,222 @@ +//! Seeding helpers: members, meta entities, results, and code-ref history. + +use ents_model::trailer::Trailers; +use ents_model::{Member, MemberId, Provenance, Status, namespace}; +use gix::refs::FullName; +use gix_hash::ObjectId; +use gix_object::{Find, Kind, Write}; + +use crate::commit::{CommitSpec, write_commit}; +use crate::keys::Keypair; +use crate::refs::MemRefStore; + +/// Write the empty tree into `objects` and return its id. +/// +/// # Examples +/// +/// ``` +/// use ents_testutil::{ObjectStore, empty_tree}; +/// +/// let objects = ObjectStore::default(); +/// let tree = empty_tree(&objects); +/// assert_eq!(tree.to_string(), "4b825dc642cb6eb9a060e54bf8d69288fbee4904"); +/// ``` +pub fn empty_tree(objects: &impl Write) -> ObjectId { + objects + .write_buf(Kind::Tree, b"") + .expect("in-memory object write cannot fail") +} + +/// Serialize `entity` as its typed tree and land it on `refname` as a +/// mutation commit carrying the `Ents-Ref:` trailer, signed by `signer` +/// when one is given. Parents come from `refname`'s current tip. Returns +/// the new tip. +/// +/// # Examples +/// +/// ``` +/// use ents_testutil::{Keypair, MemRefStore, ObjectStore, write_meta_entity}; +/// use gix_ref_store::RefStoreRead; +/// +/// let refs = MemRefStore::default(); +/// let objects = ObjectStore::default(); +/// let name: gix::refs::FullName = "refs/meta/issues/1".try_into().expect("valid"); +/// +/// let issue = ents_model::Issue { +/// title: "t".into(), +/// body: "b".into(), +/// state: "open".into(), +/// assignees: vec![], +/// labels: vec![], +/// }; +/// let tip = write_meta_entity(&refs, &objects, name.clone(), &issue, None, 1_000); +/// assert_eq!(refs.get(name.as_ref()).expect("readable"), Some(tip)); +/// ``` +pub fn write_meta_entity<T: for<'facet> facet::Facet<'facet>>( + refs: &MemRefStore, + objects: &(impl Write + Find), + refname: FullName, + entity: &T, + signer: Option<&Keypair>, + seconds: i64, +) -> ObjectId { + let tree = + facet_git_tree::serialize_into(entity, objects).expect("fixture entity always serializes"); + let trailers = Trailers { + ents_ref: Some(refname.clone()), + schema_version: None, + }; + let message = format!("Mutate {}\n\n{}", refname.as_bstr(), trailers.render()); + let parents = crate::refs_get(refs, &refname).into_iter().collect(); + let tip = write_commit( + objects, + &CommitSpec { + tree, + parents, + message, + seconds, + }, + signer, + ); + refs.set(refname.as_ref(), tip); + tip +} + +/// Enroll `id` as a member whose key is `key`'s public half, active, with +/// the given provenance, and return the member ref's new tip. +/// +/// The enrollment commit is signed by the member's own key — the +/// self-admitting shape the bootstrap window admits. +/// +/// # Examples +/// +/// ``` +/// use ents_model::Provenance; +/// use ents_testutil::{Keypair, MemRefStore, ObjectStore, enroll_member}; +/// +/// let refs = MemRefStore::default(); +/// let objects = ObjectStore::default(); +/// enroll_member(&refs, &objects, "jdc", &Keypair::from_seed(1), Provenance::AdminRegistered, 500); +/// ``` +pub fn enroll_member( + refs: &MemRefStore, + objects: &(impl Write + Find), + id: &str, + key: &Keypair, + provenance: Provenance, + seconds: i64, +) -> ObjectId { + let member = Member::new(key.public_openssh(), provenance); + write_member(refs, objects, id, &member, Some(key), seconds) +} + +/// Land an arbitrary [`Member`] state on `refs/meta/member/<id>` — the +/// general form of [`enroll_member`], for revocations, unrevocations, and +/// promotions, signed by whoever `signer` is (an admin, usually). +/// +/// # Examples +/// +/// ``` +/// use ents_model::{Member, Provenance}; +/// use ents_testutil::{Keypair, MemRefStore, ObjectStore, write_member}; +/// +/// let refs = MemRefStore::default(); +/// let objects = ObjectStore::default(); +/// let key = Keypair::from_seed(1); +/// +/// let mut member = Member::new(key.public_openssh(), Provenance::AdminRegistered); +/// member.revoke(); +/// write_member(&refs, &objects, "jdc", &member, Some(&key), 900); +/// ``` +pub fn write_member( + refs: &MemRefStore, + objects: &(impl Write + Find), + id: &str, + member: &Member, + signer: Option<&Keypair>, + seconds: i64, +) -> ObjectId { + let refname = namespace::member_ref(&MemberId::new(id)).expect("valid member id in fixture"); + write_meta_entity(refs, objects, refname, member, signer, seconds) +} + +/// Record a result of `status` for `effect` on the commit whose hex id +/// starts with `short_oid`, at the canonical +/// `refs/meta/results/<effect>/<short_oid>` ref (`effect.results-writeback`). +/// +/// The result commit is unsigned unless `signer` is given — query tests +/// exercise scan semantics, gate tests exercise signatures. +/// +/// # Examples +/// +/// ``` +/// use ents_model::Status; +/// use ents_testutil::{MemRefStore, ObjectStore, record_result}; +/// +/// let refs = MemRefStore::default(); +/// let objects = ObjectStore::default(); +/// record_result(&refs, &objects, "unit", "abc123", Status::Pass, None, 1_000); +/// ``` +pub fn record_result( + refs: &MemRefStore, + objects: &(impl Write + Find), + effect: &str, + short_oid: &str, + status: Status, + signer: Option<&Keypair>, + seconds: i64, +) -> ObjectId { + let refname = + namespace::result_ref(effect, short_oid).expect("valid result segments in fixture"); + write_meta_entity(refs, objects, refname, &status, signer, seconds) +} + +/// Append `count` empty-tree commits on top of `refname`'s current tip +/// (creating the ref if absent), one second apart starting at +/// `start_seconds`, and return the new commits oldest-first. +/// +/// # Examples +/// +/// ``` +/// use ents_testutil::{MemRefStore, ObjectStore, advance_ref}; +/// use gix_ref_store::RefStoreRead; +/// +/// let refs = MemRefStore::default(); +/// let objects = ObjectStore::default(); +/// let commits = advance_ref(&refs, &objects, "refs/heads/main", 3, 100); +/// assert_eq!(commits.len(), 3); +/// +/// let name: gix::refs::FullName = "refs/heads/main".try_into().expect("valid"); +/// assert_eq!(refs.get(name.as_ref()).expect("readable"), commits.last().copied()); +/// ``` +pub fn advance_ref( + refs: &MemRefStore, + objects: &(impl Write + Find), + refname: &str, + count: usize, + start_seconds: i64, +) -> Vec<ObjectId> { + let name: FullName = refname.try_into().expect("valid refname in fixture"); + let tree = empty_tree(objects); + let mut tip = crate::refs_get(refs, &name); + let mut out = Vec::with_capacity(count); + for i in 0..count { + let seconds = start_seconds.saturating_add(i64::try_from(i).unwrap_or(i64::MAX)); + let commit = write_commit( + objects, + &CommitSpec { + tree, + parents: tip.into_iter().collect(), + message: format!("{refname} commit {i} at {seconds}"), + seconds, + }, + None, + ); + tip = Some(commit); + out.push(commit); + } + if let Some(tip) = tip { + refs.set(name.as_ref(), tip); + } + out +}