From 75ce19da00528ff28b1ddd41256ad836b8381961 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 9 Aug 2026 23:54:10 +0000 Subject: [PATCH] Implement embedded HTTPS Git synchronization --- Cargo.lock | 1666 +++++++++++++++- Cargo.toml | 5 + DEPENDENCIES.md | 5 +- README.md | 2 + crates/storage/Cargo.toml | 7 +- crates/storage/src/crypto.rs | 16 +- crates/storage/src/git.rs | 2464 ++++++++++++++++++++++++ crates/storage/src/lib.rs | 1 + crates/storage/src/mutation.rs | 38 + crates/storage/src/repository.rs | 2 +- crates/storage/tests/git_embedded.rs | 558 ++++++ crates/storage/tests/tree_mutations.rs | 11 + docs/git-synchronization.md | 47 + docs/recipient-policies.md | 4 +- 14 files changed, 4811 insertions(+), 15 deletions(-) create mode 100644 crates/storage/src/git.rs create mode 100644 crates/storage/tests/git_embedded.rs create mode 100644 docs/git-synchronization.md diff --git a/Cargo.lock b/Cargo.lock index 8a12235..a855713 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,7 +43,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -211,6 +211,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "argon2" version = "0.5.3" @@ -219,7 +228,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", "zeroize", ] @@ -456,6 +465,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "base16ct" version = "0.2.0" @@ -483,6 +515,21 @@ dependencies = [ "serde", ] +[[package]] +name = "bisync" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5020822f6d6f23196ccaf55e228db36f9de1cf788052b37992e17cbc96ec41a7" +dependencies = [ + "bisync_macros", +] + +[[package]] +name = "bisync_macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d21f40d350a700f6aa107e45fb26448cf489d34794b2ba4522181dc9f1173af6" + [[package]] name = "bit-set" version = "0.5.3" @@ -632,6 +679,17 @@ dependencies = [ "cipher", ] +[[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + [[package]] name = "buffer-redux" version = "1.1.0" @@ -874,6 +932,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "cipher" version = "0.4.4" @@ -963,6 +1032,15 @@ dependencies = [ "x11rb", ] +[[package]] +name = "clru" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "cmac" version = "0.7.2" @@ -974,6 +1052,15 @@ dependencies = [ "digest", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "codespan-reporting" version = "0.12.0" @@ -1142,6 +1229,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc24" version = "0.1.6" @@ -1163,6 +1259,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.22" @@ -1279,7 +1384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -1385,6 +1490,20 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "dbl" version = "0.3.2" @@ -1394,6 +1513,37 @@ dependencies = [ "generic-array", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.19", +] + [[package]] name = "deltae" version = "0.3.2" @@ -1571,6 +1721,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "eax" version = "0.5.0" @@ -1654,6 +1810,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -1758,6 +1923,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" +[[package]] +name = "faster-hex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +dependencies = [ + "heapless", + "serde", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -1792,6 +1967,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1936,6 +2121,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "funty" version = "2.0.0" @@ -2071,8 +2262,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -2094,8 +2287,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -2108,6 +2304,833 @@ dependencies = [ "polyval", ] +[[package]] +name = "gix" +version = "0.86.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb3790fd8981cba7949f1ba924ef865d902df731627bc5998d14164063892fce" +dependencies = [ + "gix-actor", + "gix-attributes", + "gix-command", + "gix-commitgraph", + "gix-config", + "gix-credentials", + "gix-date", + "gix-diff", + "gix-discover", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-hashtable", + "gix-ignore", + "gix-index", + "gix-lock", + "gix-merge", + "gix-negotiate", + "gix-object", + "gix-odb", + "gix-pack", + "gix-path", + "gix-pathspec", + "gix-prompt", + "gix-protocol", + "gix-ref", + "gix-refspec", + "gix-revision", + "gix-revwalk", + "gix-sec", + "gix-shallow", + "gix-submodule", + "gix-tempfile", + "gix-trace", + "gix-transport", + "gix-traverse", + "gix-url", + "gix-utils", + "gix-validate", + "gix-worktree", + "gix-worktree-stream", + "gix-zlib", + "nonempty", + "smallvec", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-actor" +version = "0.41.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f9308ad6fd35b2a865cbe4117ac61b2be59e4a9ef1621c7a9794f7c8e52c5b" +dependencies = [ + "bstr", + "gix-date", + "gix-error", +] + +[[package]] +name = "gix-attributes" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31c593692ebdc1e38858d9a2b56f6a594c501e24a38971fe6685571f5a07be0" +dependencies = [ + "bstr", + "gix-features", + "gix-glob", + "gix-path", + "gix-quote", + "gix-trace", + "smallvec", + "thiserror 2.0.19", + "unicode-bom", +] + +[[package]] +name = "gix-bitmap" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd1d118d0f5d88b96e6f6e13b566475fef4797ead4a02c26fed36c1375066f7" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-chunk" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a871e5cab12ba568845714473505deefffb3c04eb47f4708ce344cd459c1cc" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-command" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4363accdf6ef7ba861871d2d521ab7418a04aaaed919fadb022af71d379b12" +dependencies = [ + "bstr", + "gix-path", + "gix-quote", + "gix-trace", + "shell-words", +] + +[[package]] +name = "gix-commitgraph" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2cd7f054ae2727223fe46dd39c012f066b12f532962d336d29ee193261787da" +dependencies = [ + "bstr", + "gix-chunk", + "gix-error", + "gix-hash", + "memmap2", + "nonempty", +] + +[[package]] +name = "gix-config" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "103d11bef95c467577ecfa8b7b86a22e65af3507b2c9bfa3809a4afbae7df301" +dependencies = [ + "bstr", + "gix-config-value", + "gix-features", + "gix-glob", + "gix-path", + "gix-ref", + "gix-sec", + "gix-utils", + "smallvec", + "thiserror 2.0.19", + "unicode-bom", +] + +[[package]] +name = "gix-config-value" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f6af5321bfd3711a279d6b244d58532ba1cfabf9eb6374791f19929d8970082" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "gix-path", + "libc", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-credentials" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbdb1c417980d05d547c19fc2b63344b5257c2387048d69ffe957bda142b59" +dependencies = [ + "bstr", + "gix-command", + "gix-config-value", + "gix-date", + "gix-path", + "gix-prompt", + "gix-quote", + "gix-sec", + "gix-trace", + "gix-url", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-date" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e47b9e8cdc688296609b706428de570f88b1e0eed7156dde7b4a89d26fa4567" +dependencies = [ + "bstr", + "gix-error", + "itoa", + "jiff", +] + +[[package]] +name = "gix-diff" +version = "0.66.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fee7d89a3c507491cdfc57a1d1e0e300214720b4f7709ebc253e422f99822bfc" +dependencies = [ + "bstr", + "gix-command", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-imara-diff", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-worktree", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-discover" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f517766fa1101dfe2606c1a19a8ffa699099030995a9194445446dfe261bdf" +dependencies = [ + "bstr", + "dunce", + "gix-fs", + "gix-path", + "gix-ref", + "gix-sec", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-error" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9292309fd944e71b2a3c96d3c03a6feb8852db646febdde7cbb9f79cb5f329" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-features" +version = "0.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20aa09e83a48dc02c5f5f08578aa79d3ab1bab4618b8c362f88684645a02bdcc" +dependencies = [ + "bytes", + "crc32fast", + "crossbeam-channel", + "gix-path", + "gix-trace", + "gix-utils", + "libc", + "once_cell", + "parking_lot", + "prodash", + "walkdir", +] + +[[package]] +name = "gix-filter" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e7b5dbf524d97e839f642930c76d7f011c0791e7d11d8148989ac5af7c76aa8" +dependencies = [ + "bstr", + "encoding_rs", + "gix-attributes", + "gix-command", + "gix-hash", + "gix-object", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-trace", + "gix-utils", + "smallvec", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-fs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "865cf13fcaf5455220546cb9607c416bd1be9a6caafd143655a362fdeab64e80" +dependencies = [ + "bstr", + "gix-features", + "gix-path", + "gix-utils", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-glob" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421e92a711554fa5827d1b0599d3389acdd0f6729e97a8c5a57d79af1e50bf36" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "gix-features", + "gix-path", +] + +[[package]] +name = "gix-hash" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13adaa73415fd6c902310923f68d0b98e8cecf14b33ea58c02cc387cee56f54e" +dependencies = [ + "faster-hex", + "gix-features", + "sha1-checked", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-hashtable" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78fccd6fea3bcf0b39c076bae60ae49b08daaf538b950202101a981f9d3c01d3" +dependencies = [ + "gix-hash", + "hashbrown 0.17.1", + "parking_lot", +] + +[[package]] +name = "gix-ignore" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cff8e8aa125e39377456073e63df3334d9e5741372ddcc226198015076dda2" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-trace", + "unicode-bom", +] + +[[package]] +name = "gix-imara-diff" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1" +dependencies = [ + "bstr", + "hashbrown 0.17.1", +] + +[[package]] +name = "gix-index" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5009c4e7e9f9b4cfaaab1153e49133eb04d79c015b5702d6c3d2ab94271a89c6" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "filetime", + "fnv", + "gix-bitmap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-traverse", + "gix-utils", + "gix-validate", + "hashbrown 0.17.1", + "itoa", + "libc", + "memmap2", + "rustix 1.1.4", + "smallvec", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-lock" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c69157820343bf1c6e4b88b9808e920900de02e18aaf5862b30ada43814848" +dependencies = [ + "gix-tempfile", + "gix-utils", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-merge" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7b2e202fa474a3dd0bbd551be35adf199be0bf5c925fd7707fff2fdb520f651" +dependencies = [ + "bstr", + "gix-command", + "gix-diff", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-imara-diff", + "gix-index", + "gix-object", + "gix-path", + "gix-quote", + "gix-revision", + "gix-revwalk", + "gix-tempfile", + "gix-trace", + "gix-worktree", + "nonempty", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-negotiate" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fa5aa789990f124e4f559b142cecfb60662cb4ae17006684ea9d668f4f07689" +dependencies = [ + "bitflags 2.13.1", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-object", + "gix-revwalk", +] + +[[package]] +name = "gix-object" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e48c235e7f886eb819fc878af75be889333dd3c38bee02ed7af48ae2cf596c4" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-utils", + "gix-validate", + "itoa", + "smallvec", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-odb" +version = "0.83.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dd494ffb5037e62b8220109e894d2861ff2150a2cacbfccdba57ae1ebab2b96" +dependencies = [ + "arc-swap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-pack", + "gix-path", + "gix-quote", + "gix-zlib", + "memmap2", + "parking_lot", + "tempfile", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-pack" +version = "0.73.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d5446127b269706e85998065267ddd2ccc3550179da6780b22fe496175ccb20" +dependencies = [ + "clru", + "gix-chunk", + "gix-error", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-zlib", + "memmap2", + "parking_lot", + "smallvec", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-packetline" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3766025c72319c4accdd854a18e6f0dd176c8eb0f3bc8a60a7765be2b50cabf2" +dependencies = [ + "bstr", + "faster-hex", + "gix-trace", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-path" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "751d6bd162106f8c1e7e9aaccb5bbdd605267e91a930a17a4560c46e33a9100c" +dependencies = [ + "bstr", + "gix-trace", + "gix-validate", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-pathspec" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f6fa5f8007f008187c3f60b4373209ca83d1cc947f35ede03e16cd15a4d137" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "gix-attributes", + "gix-config-value", + "gix-glob", + "gix-path", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-prompt" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cb1f1eb92d6f4c9d4c105a6ca912cff637fbd5cacbcbafa5deccd88bfaa3565" +dependencies = [ + "gix-command", + "gix-config-value", + "parking_lot", + "rustix 1.1.4", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-protocol" +version = "0.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dede40e89c1e90f548415f50636bb051f6d9c60f68b8b710bc07825722d19588" +dependencies = [ + "bisync", + "bstr", + "gix-credentials", + "gix-date", + "gix-features", + "gix-hash", + "gix-lock", + "gix-negotiate", + "gix-object", + "gix-ref", + "gix-refspec", + "gix-revwalk", + "gix-shallow", + "gix-trace", + "gix-transport", + "gix-utils", + "nonempty", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-quote" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e541fc33cc2b783b7979040d445a0c86a2eca747c8faea4ca84230d06ae6ef" +dependencies = [ + "bstr", + "gix-error", + "gix-utils", +] + +[[package]] +name = "gix-ref" +version = "0.66.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeb0c90a8f6202ceaaa22996cbf837c943ccb2d8af9ff3490f0758305e6b7883" +dependencies = [ + "gix-actor", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-utils", + "gix-validate", + "memmap2", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-refspec" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7406282cc0259b51f6aee299ca3d31279a020530363152a2e6c96e8a7f7bbc83" +dependencies = [ + "bstr", + "gix-error", + "gix-glob", + "gix-hash", + "gix-revision", + "gix-validate", + "smallvec", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-revision" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e55e09d4a1ecf2beecc8c09cafcad37979e805b31f588b0e957e191df5783681" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "gix-trace", + "nonempty", +] + +[[package]] +name = "gix-revwalk" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c113c0a53294dc6280ffc06cbcc4f50f820397e97d6a00b429a44b8db26e29" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "smallvec", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-sec" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4fe6c152c1d50aea36f299825702cd37e303307832fec1d0fdd5844e47ce2f" +dependencies = [ + "bitflags 2.13.1", + "gix-path", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "gix-shallow" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ecc9f4b40537043e4bbd7d3d1760e74fb8e7b07a546166b558acaa73ad97f4a" +dependencies = [ + "bstr", + "gix-hash", + "gix-lock", + "nonempty", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-submodule" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd98077a56d08886112e6b08dc94076d03539f4bc0b9d7880e4be2b8a640d8c" +dependencies = [ + "bstr", + "gix-config", + "gix-path", + "gix-pathspec", + "gix-refspec", + "gix-url", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-tempfile" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b675b920bd5a61d17ad542772f03ec34c60feb8ff683e1560c03ae967363731e" +dependencies = [ + "dashmap", + "gix-fs", + "libc", + "parking_lot", + "tempfile", +] + +[[package]] +name = "gix-trace" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be3eb81d9dc914335923e50d52829c551feefd6a72d176c4130c546b67a60814" + +[[package]] +name = "gix-transport" +version = "0.58.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f36d045b840f8aeee1a527e677eab1fbebfbbe94bf2e708fa81d0b4b742d5fc" +dependencies = [ + "base64", + "bstr", + "gix-command", + "gix-credentials", + "gix-features", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-sec", + "gix-url", + "parking_lot", + "reqwest", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-traverse" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008c5cd879e46e86b5c2469e633611978b18775d53d05668d691bc13088bd409" +dependencies = [ + "bitflags 2.13.1", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "smallvec", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-url" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31bdfc93aa880cda3272718a5879ce3aa7723fa13514320dd6608151607afe72" +dependencies = [ + "bstr", + "gix-path", + "gix-utils", + "percent-encoding", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-utils" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1795bd2a970ca8b2185318c2abb97d955c71992f1cf28de73ad3b593a9f3ce8" +dependencies = [ + "bstr", + "fastrand", + "getrandom 0.4.3", + "unicode-normalization", +] + +[[package]] +name = "gix-validate" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a034e84d1e04e1b1f20f51f12491da230b6ac8b925d0c8e1b89bcd87a7c5ccc" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-worktree" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31eb8e675122e83585e461fe28f68ff8c5ed55b49017b697e7e76423ff973424" +dependencies = [ + "bstr", + "gix-attributes", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-validate", +] + +[[package]] +name = "gix-worktree-stream" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b088c8724e7be120c4798dd86925cf05332c9d356a463542578600c50c7a549" +dependencies = [ + "gix-attributes", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-object", + "gix-path", + "gix-traverse", + "parking_lot", +] + +[[package]] +name = "gix-zlib" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e8813f5579b3075ff9c90f7c59cd2b62b4ebb639361f0911648b22d7446cc7c" +dependencies = [ + "thiserror 2.0.19", + "zlib-rs", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -2235,6 +3258,25 @@ dependencies = [ "svg_fmt", ] +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -2260,6 +3302,21 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -2291,6 +3348,16 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -2333,6 +3400,104 @@ dependencies = [ "digest", ] +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iced" version = "0.14.0" @@ -2714,11 +3879,14 @@ dependencies = [ "cap-tempfile", "clap", "flate2", + "gix", + "gix-config", "hex", "pgp", "rand 0.8.7", "rand_chacha 0.3.1", "regex", + "reqwest", "rustix 1.1.4", "serde", "sha1", @@ -2785,6 +3953,59 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.22.4" @@ -2895,7 +4116,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -3052,6 +4273,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac_address" version = "1.1.8" @@ -3132,6 +4359,12 @@ dependencies = [ "paste", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -3273,6 +4506,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -3756,6 +4995,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "orbclient" version = "0.3.55" @@ -4186,7 +5431,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -4263,6 +5508,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prodash" +version = "31.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c" +dependencies = [ + "parking_lot", +] + [[package]] name = "profiling" version = "1.0.18" @@ -4278,6 +5532,63 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.19", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.19", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -4326,6 +5637,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -4364,6 +5686,21 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "range-alloc" version = "0.1.5" @@ -4573,6 +5910,46 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51743d3e274e2b18df81c4dc6caf8a5b8e15dbe799e0dca05c7617380094e884" +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -4583,6 +5960,20 @@ dependencies = [ "subtle", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "ripemd" version = "0.1.3" @@ -4675,6 +6066,81 @@ dependencies = [ "rustix 1.1.4", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -4696,6 +6162,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -4756,6 +6231,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -4862,7 +6360,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4884,7 +6382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4898,6 +6396,12 @@ dependencies = [ "keccak", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -5119,6 +6623,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "softbuffer" version = "0.4.8" @@ -5276,6 +6790,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -5533,6 +7056,44 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -5608,6 +7169,51 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -5640,6 +7246,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -5687,6 +7299,12 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-bom" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -5699,6 +7317,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-script" version = "0.5.8" @@ -5863,6 +7490,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -5872,6 +7505,7 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -5924,6 +7558,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -6163,6 +7806,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weedle2" version = "5.0.0" diff --git a/Cargo.toml b/Cargo.toml index 2b6bf23..2484aec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,13 +18,18 @@ cap-std = "4.0" cap-tempfile = "4.0" clap = { version = "4.6", features = ["derive"] } crossterm = "0.29" +flate2 = "1.1" +gix = { version = "0.86", default-features = false, features = ["blocking-http-transport-reqwest-rust-tls", "index", "merge", "revision", "sha1", "tree-editor"] } +gix-config = "0.59" iced = "0.14" ironstorage = { path = "crates/storage" } pgp = { version = "0.20", default-features = false } rand = "0.8" regex = "1.13" +reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] } ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color"] } serde = { version = "1", features = ["derive"] } +sha1 = "0.10" shlex = "1.3" toml = "0.9" uniffi = "0.32" diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index 6800f6f..9e8560a 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -18,6 +18,9 @@ The current direct dependencies are: | [crossterm 0.29](https://crates.io/crates/crossterm/0.29.0) | Terminal I/O | MIT | | [Ratatui 0.30](https://crates.io/crates/ratatui/0.30.2) | TUI | MIT | | [Iced 0.14](https://crates.io/crates/iced/0.14.0) | Desktop UI | MIT | +| [gix 0.86](https://crates.io/crates/gix/0.86.0), [gix-config 0.59](https://crates.io/crates/gix-config/0.59.0) | Embedded Git objects, index, references, fetch, and merge | MIT OR Apache-2.0 | +| [reqwest 0.13](https://crates.io/crates/reqwest/0.13.4) | HTTPS smart-Git transport with Rustls | MIT OR Apache-2.0 | +| [flate2 1.1](https://crates.io/crates/flate2/1.1.9), [sha1 0.10](https://crates.io/crates/sha1/0.10.7) | Git pack compression and checksums | MIT OR Apache-2.0 | | [pgp 0.20](https://crates.io/crates/pgp/0.20.0) | Embedded OpenPGP key import, encryption, decryption, and signatures | MIT OR Apache-2.0 | | [rand 0.8](https://crates.io/crates/rand/0.8.7) | Operating-system-backed cryptographic randomness for OpenPGP operations | MIT OR Apache-2.0 | | [regex 1.13](https://crates.io/crates/regex/1.13.1) | Linear-time byte-oriented decrypted grep matching | MIT OR Apache-2.0 | @@ -43,7 +46,7 @@ decision. | GPG-compatible packets, encryption, and transferable keys | [`pgp` 0.20](https://crates.io/crates/pgp/0.20.0) | MIT OR Apache-2.0 | Selected with default features disabled. The fixture harness proves armored/binary protected key import, packet validation, GPG-compatible decryption, multi-recipient encryption, and detached signatures without native libraries or processes. | | Alternative GPG implementation | [`sequoia-openpgp` 2.4](https://crates.io/crates/sequoia-openpgp/2.4.1) | LGPL-2.0-or-later | Hold in reserve. Its default Nettle backend is native; its Rust backend exists, but the LGPL adds distribution work we can avoid. | | GnuPG integration | [`gpgme` 0.11](https://crates.io/crates/gpgme/0.11.0) | LGPL-2.1 | Reject: native GPGME/GnuPG integration and GPG engine processes violate the portability and no-process requirements. | -| Local Git plus HTTPS fetch/push | [`gix` 0.86](https://crates.io/crates/gix/0.86.0) | MIT OR Apache-2.0 | Preferred with default features off and `blocking-http-transport-reqwest-rust-tls`; accept HTTPS remotes only and supply credentials directly. | +| Local Git plus HTTPS fetch/push | [`gix` 0.86](https://crates.io/crates/gix/0.86.0) | MIT OR Apache-2.0 | Selected with default features off and `blocking-http-transport-reqwest-rust-tls`; accept HTTPS remotes only, supply credentials directly, and use the storage-owned receive-pack implementation for push. | | Git FFI fallback | [`git2` 0.21](https://crates.io/crates/git2/0.21.0) | MIT OR Apache-2.0 | Reject for now; it links libgit2 and is unnecessary for the HTTPS-only scope. | | Server/application credentials | [`keyring-core` 1.0](https://crates.io/crates/keyring-core/1.0.0), [`apple-native-keyring-store`](https://crates.io/crates/apple-native-keyring-store/1.0.2), [`windows-native-keyring-store`](https://crates.io/crates/windows-native-keyring-store/1.1.0), [`zbus-secret-service-keyring-store`](https://crates.io/crates/zbus-secret-service-keyring-store/1.0.0) | MIT OR Apache-2.0 | Preferred per-platform stores. The Apple protected store supports iOS/macOS protected data and biometric access. Use the Linux store's Rust crypto feature. | | Secret values in memory | [`secrecy` 0.10](https://crates.io/crates/secrecy/0.10.3), [`zeroize` 1.9](https://crates.io/crates/zeroize/1.9.0) | MIT OR Apache-2.0 | `zeroize` selected for the storage-owned redacted byte type; consider `secrecy` only when typed exposure controls add value. | diff --git a/README.md b/README.md index 759537e..2f37407 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ Candidate libraries and the pending project-license decision are tracked in The shared TOML schema, path rules, editor precedence, and HTTPS remote format are documented in [`docs/configuration.md`](docs/configuration.md). +Embedded Git, HTTPS synchronization, merge behavior, and commit signing are +documented in [`docs/git-synchronization.md`](docs/git-synchronization.md). The capability-scoped password-store layout and atomic mutation guarantees are documented in [`docs/repository-core.md`](docs/repository-core.md). The embedded OpenPGP backend, exported-key model, secret-provider boundary, and diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 933e126..6525a4b 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -10,21 +10,24 @@ publish = false cap-std.workspace = true cap-tempfile.workspace = true clap.workspace = true +flate2.workspace = true +gix.workspace = true +gix-config.workspace = true pgp.workspace = true rand.workspace = true regex.workspace = true +reqwest.workspace = true serde.workspace = true +sha1.workspace = true shlex.workspace = true toml.workspace = true url.workspace = true zeroize.workspace = true [dev-dependencies] -flate2 = "1.1" hex = "0.4" rand_chacha = "0.3" rustix = { version = "1.1", features = ["fs"] } -sha1 = "0.10" sha2 = "0.10" smallvec = "1.15" tempfile = "3" diff --git a/crates/storage/src/crypto.rs b/crates/storage/src/crypto.rs index fd991c1..3014b67 100644 --- a/crates/storage/src/crypto.rs +++ b/crates/storage/src/crypto.rs @@ -11,8 +11,8 @@ use std::{ use cap_std::{ambient_authority, fs::Dir}; use pgp::{ composed::{ - Deserializable, DetachedSignature, Esk, Message, MessageBuilder, PublicOrSecret, - SignedPublicKey, SignedPublicSubKey, SignedSecretKey, SubpacketConfig, + ArmorOptions, Deserializable, DetachedSignature, Esk, Message, MessageBuilder, + PublicOrSecret, SignedPublicKey, SignedPublicSubKey, SignedSecretKey, SubpacketConfig, }, crypto::{hash::HashAlgorithm, sym::SymmetricKeyAlgorithm}, packet::{SignatureType, Subpacket, SubpacketData}, @@ -492,6 +492,18 @@ impl KeyStore { Ok(DetachedSignatureBytes(bytes)) } + /// Encode a detached signature as the ASCII armor required by Git's + /// `gpgsig` commit header. + pub fn armor_signature( + &self, + signature: &DetachedSignatureBytes, + ) -> Result, CryptoError> { + DetachedSignature::from_bytes(Cursor::new(signature.as_bytes())) + .map_err(|_| CryptoError::SigningFailed)? + .to_armored_bytes(ArmorOptions::default()) + .map_err(|_| CryptoError::SigningFailed) + } + /// Verify a detached `.gpg-id.sig` against an explicit set of allowed primary identities. pub fn verify( &self, diff --git a/crates/storage/src/git.rs b/crates/storage/src/git.rs new file mode 100644 index 0000000..f46b62d --- /dev/null +++ b/crates/storage/src/git.rs @@ -0,0 +1,2464 @@ +//! Embedded Git repository management and pass-compatible automatic commits. +//! +//! Repositories are always opened in isolated mode: only repository-local +//! configuration is read, environment variables and global configuration are +//! ignored, and no filter, credential helper, or transport program is run. + +use std::{ + collections::{BTreeMap, BTreeSet}, + error::Error, + fmt, fs, + path::{Component, Path, PathBuf}, +}; + +use flate2::{Compression, write::ZlibEncoder}; +use gix::{ + bstr::{BString, ByteSlice}, + objs::tree::EntryKind, +}; +use sha1::{Digest as _, Sha1}; + +use crate::{ + config::{ApplicationId, GitRemote, ServerId}, + crypto::{KeyHandle, KeyStore, SecretProvider}, + mutation::{TreeCommit, TreeCommitError, TreeCommitter}, + recipient::{PolicyCommit, PolicyCommitError, PolicyCommitter}, + repository::Repository, + write::{EntryCommit, EntryCommitError, EntryCommitter}, +}; + +const DEFAULT_BRANCH: &str = "main"; +const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GitIdentity { + name: String, + email: String, +} + +impl GitIdentity { + pub fn new(name: impl Into, email: impl Into) -> Result { + let name = name.into(); + let email = email.into(); + if name.trim().is_empty() + || email.trim().is_empty() + || name.contains(['\n', '\r', '<', '>']) + || email.contains(['\n', '\r', '<', '>']) + { + return Err(GitError::InvalidIdentity); + } + Ok(Self { name, email }) + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn email(&self) -> &str { + &self.email + } + + fn signature(&self) -> gix::actor::Signature { + gix::actor::Signature { + name: self.name.clone().into(), + email: self.email.clone().into(), + time: gix::date::Time::now_local_or_utc(), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GitChangeKind { + Added, + Modified, + Deleted, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GitChange { + path: PathBuf, + kind: GitChangeKind, +} + +impl GitChange { + pub fn path(&self) -> &Path { + &self.path + } + + pub fn kind(&self) -> GitChangeKind { + self.kind + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct GitStatus { + staged: Vec, + unstaged: Vec, +} + +impl GitStatus { + pub fn staged(&self) -> &[GitChange] { + &self.staged + } + + pub fn unstaged(&self) -> &[GitChange] { + &self.unstaged + } + + pub fn is_clean(&self) -> bool { + self.staged.is_empty() && self.unstaged.is_empty() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GitLogEntry { + id: String, + parents: Vec, + author_name: String, + author_email: String, + message: String, + timestamp: i64, +} + +impl GitLogEntry { + pub fn id(&self) -> &str { + &self.id + } + pub fn parents(&self) -> &[String] { + &self.parents + } + pub fn author_name(&self) -> &str { + &self.author_name + } + pub fn author_email(&self) -> &str { + &self.author_email + } + pub fn message(&self) -> &str { + &self.message + } + pub fn timestamp(&self) -> i64 { + self.timestamp + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GitDiffEntry { + path: PathBuf, + old: Option>, + new: Option>, +} + +impl GitDiffEntry { + pub fn path(&self) -> &Path { + &self.path + } + pub fn old(&self) -> Option<&[u8]> { + self.old.as_deref() + } + pub fn current(&self) -> Option<&[u8]> { + self.new.as_deref() + } +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum GitError { + NotRepository, + AlreadyRepository, + BareRepository, + InvalidIdentity, + InvalidPath { + path: PathBuf, + }, + UnsafeWorktreeObject { + path: PathBuf, + }, + FileTooLarge { + path: PathBuf, + }, + NoChanges, + UnbornHead, + DirtyWorktree, + InvalidRemoteName, + ForbiddenRemoteUrl, + RemoteNotFound { + name: String, + }, + CredentialsUnavailable, + AuthenticationFailed, + NonFastForward, + MergeConflicts { + paths: Vec, + }, + InvalidRepository(String), + Io { + operation: &'static str, + path: PathBuf, + }, +} + +impl fmt::Display for GitError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotRepository => formatter.write_str("no Git repository was found"), + Self::AlreadyRepository => formatter.write_str("a Git repository already exists here"), + Self::BareRepository => { + formatter.write_str("a bare Git repository has no password-store worktree") + } + Self::InvalidIdentity => formatter.write_str("the Git author identity is invalid"), + Self::InvalidPath { path } => write!(formatter, "invalid Git path: {}", path.display()), + Self::UnsafeWorktreeObject { path } => { + write!(formatter, "unsafe worktree object: {}", path.display()) + } + Self::FileTooLarge { path } => { + write!(formatter, "worktree file is too large: {}", path.display()) + } + Self::NoChanges => formatter.write_str("there are no staged changes to commit"), + Self::UnbornHead => formatter.write_str("the repository has no commits"), + Self::DirtyWorktree => formatter.write_str("the worktree has uncommitted changes"), + Self::InvalidRemoteName => formatter.write_str("the Git remote name is invalid"), + Self::ForbiddenRemoteUrl => { + formatter.write_str("Git remotes must use credential-free HTTPS URLs") + } + Self::RemoteNotFound { name } => write!(formatter, "Git remote not found: {name}"), + Self::CredentialsUnavailable => { + formatter.write_str("HTTPS Git credentials are unavailable") + } + Self::AuthenticationFailed => formatter.write_str("HTTPS Git authentication failed"), + Self::NonFastForward => formatter.write_str("the remote update is not a fast-forward"), + Self::MergeConflicts { paths } => write!( + formatter, + "the merge has conflicts in {} path(s)", + paths.len() + ), + Self::InvalidRepository(message) => { + write!(formatter, "invalid Git repository: {message}") + } + Self::Io { operation, path } => { + write!(formatter, "could not {operation} {}", path.display()) + } + } + } +} + +impl Error for GitError {} + +pub struct GitRepository { + root: PathBuf, + repository: gix::Repository, + identity: GitIdentity, +} + +pub struct GitCredential { + username: String, + password: crate::repository::SecretBytes, +} + +impl GitCredential { + pub fn new(username: impl Into, password: Vec) -> Result { + let username = username.into(); + if username.is_empty() + || username.contains(['\n', '\r', '\0']) + || password.contains(&b'\n') + || password.contains(&b'\r') + || password.contains(&0) + { + return Err(GitError::CredentialsUnavailable); + } + Ok(Self { + username, + password: crate::repository::SecretBytes::new(password), + }) + } + + pub fn username(&self) -> &str { + &self.username + } + pub fn password(&self) -> &[u8] { + self.password.expose() + } +} + +impl fmt::Debug for GitCredential { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("GitCredential([REDACTED])") + } +} + +pub trait GitCredentialProvider { + fn credential( + &self, + server: &ServerId, + application: &ApplicationId, + ) -> Result; +} + +pub trait GitSmartHttpTransport { + fn advertise_receive_pack( + &self, + url: &url::Url, + credential: &GitCredential, + ) -> Result, GitError>; + fn receive_pack( + &self, + url: &url::Url, + credential: &GitCredential, + request: Vec, + ) -> Result, GitError>; +} + +pub trait GitFetchTransport { + fn fetch( + &self, + repository: &GitRepository, + configured: &GitRemote, + credential: &GitCredential, + ) -> Result; +} + +#[derive(Default)] +pub struct EmbeddedFetchTransport; + +impl GitFetchTransport for EmbeddedFetchTransport { + fn fetch( + &self, + repository: &GitRepository, + configured: &GitRemote, + credential: &GitCredential, + ) -> Result { + repository.fetch_embedded(configured, credential) + } +} + +#[derive(Default)] +pub struct ReqwestGitTransport; + +impl ReqwestGitTransport { + fn client() -> Result { + reqwest::blocking::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(invalid) + } + + fn endpoint(url: &url::Url, suffix: &str) -> Result { + let mut endpoint = url.clone(); + let path = endpoint.path().trim_end_matches('/'); + endpoint.set_path(&format!("{path}/{suffix}")); + Ok(endpoint) + } + + fn authenticated( + request: reqwest::blocking::RequestBuilder, + credential: &GitCredential, + ) -> Result { + let password = std::str::from_utf8(credential.password()) + .map_err(|_| GitError::CredentialsUnavailable)?; + Ok(request.basic_auth(credential.username(), Some(password))) + } + + fn response(response: reqwest::blocking::Response) -> Result, GitError> { + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return Err(GitError::AuthenticationFailed); + } + if !status.is_success() { + return Err(GitError::InvalidRepository(format!( + "Git HTTP server returned {status}" + ))); + } + response + .bytes() + .map(|bytes| bytes.to_vec()) + .map_err(invalid) + } +} + +impl GitSmartHttpTransport for ReqwestGitTransport { + fn advertise_receive_pack( + &self, + url: &url::Url, + credential: &GitCredential, + ) -> Result, GitError> { + let mut endpoint = Self::endpoint(url, "info/refs")?; + endpoint + .query_pairs_mut() + .append_pair("service", "git-receive-pack"); + let request = Self::client()? + .get(endpoint) + .header("Accept", "application/x-git-receive-pack-advertisement"); + Self::response( + Self::authenticated(request, credential)? + .send() + .map_err(invalid)?, + ) + } + + fn receive_pack( + &self, + url: &url::Url, + credential: &GitCredential, + request: Vec, + ) -> Result, GitError> { + let endpoint = Self::endpoint(url, "git-receive-pack")?; + let request = Self::client()? + .post(endpoint) + .header("Content-Type", "application/x-git-receive-pack-request") + .header("Accept", "application/x-git-receive-pack-result") + .body(request); + Self::response( + Self::authenticated(request, credential)? + .send() + .map_err(invalid)?, + ) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FetchOutcome { + remote: String, + received_pack: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PullOutcome { + UpToDate, + FastForward, + Merged, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PushOutcome { + remote: String, + branch: String, + old: Option, + new: String, +} + +impl PushOutcome { + pub fn remote(&self) -> &str { + &self.remote + } + pub fn branch(&self) -> &str { + &self.branch + } + pub fn old(&self) -> Option<&str> { + self.old.as_deref() + } + pub fn new_id(&self) -> &str { + &self.new + } +} + +impl FetchOutcome { + pub fn remote(&self) -> &str { + &self.remote + } + pub fn received_pack(&self) -> bool { + self.received_pack + } +} + +impl GitRepository { + pub fn clone_into( + destination: &Path, + identity: GitIdentity, + configured: &GitRemote, + credentials: &impl GitCredentialProvider, + ) -> Result { + Self::clone_into_with_transport( + destination, + identity, + configured, + credentials, + &EmbeddedFetchTransport, + ) + } + + pub fn clone_into_with_transport( + destination: &Path, + identity: GitIdentity, + configured: &GitRemote, + credentials: &impl GitCredentialProvider, + transport: &impl GitFetchTransport, + ) -> Result { + validate_https_remote(configured.url().as_str())?; + let parent = destination.parent().ok_or_else(|| GitError::InvalidPath { + path: destination.to_owned(), + })?; + let metadata = + fs::symlink_metadata(parent).map_err(|_| io("inspect clone parent", parent))?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(GitError::UnsafeWorktreeObject { + path: parent.to_owned(), + }); + } + if let Ok(metadata) = fs::symlink_metadata(destination) { + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(GitError::UnsafeWorktreeObject { + path: destination.to_owned(), + }); + } + if fs::read_dir(destination) + .map_err(|_| io("read clone destination", destination))? + .next() + .is_some() + { + return Err(GitError::DirtyWorktree); + } + } + let temporary = (0..128_u8) + .find_map(|attempt| { + let name = format!(".ironstorage-clone-{}-{attempt}", rand::random::()); + let candidate = parent.join(name); + match fs::create_dir(&candidate) { + Ok(()) => Some(Ok(candidate)), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => None, + Err(_) => Some(Err(io("create clone directory", parent))), + } + }) + .transpose()? + .ok_or_else(|| io("create clone directory", parent))?; + let cloned = (|| { + let store = Repository::open(&temporary).map_err(invalid)?; + gix::ThreadSafeRepository::init_opts( + store.root_path(), + gix::create::Kind::WithWorktree, + gix::create::Options::default(), + isolated_options(), + ) + .map_err(invalid)?; + let mut repository = Self::open(&store, identity.clone())?; + repository.add_remote(configured.name().as_str(), configured.url().as_str())?; + repository.pull_with_transport(configured, None, credentials, transport)?; + Ok(repository) + })(); + if let Err(error) = cloned { + let _ = fs::remove_dir_all(&temporary); + return Err(error); + } + drop(cloned); + if destination.exists() { + fs::remove_dir(destination) + .map_err(|_| io("prepare clone destination", destination))?; + } + if fs::rename(&temporary, destination).is_err() { + let _ = fs::remove_dir_all(&temporary); + return Err(GitError::Io { + operation: "install clone at", + path: destination.to_owned(), + }); + } + Self::open_at(destination.to_owned(), identity) + } + + pub fn init(store: &Repository, identity: GitIdentity) -> Result { + let root = store.root_path().to_owned(); + if root.join(".git").exists() { + return Err(GitError::AlreadyRepository); + } + let attributes_path = root.join(".gitattributes"); + let previous_attributes = match fs::read(&attributes_path) { + Ok(contents) => Some(contents), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(_) => return Err(io("read", Path::new(".gitattributes"))), + }; + gix::ThreadSafeRepository::init_opts( + &root, + gix::create::Kind::WithWorktree, + gix::create::Options::default(), + isolated_options(), + ) + .map_err(invalid)?; + let initialized = (|| { + let repository = Self::open_at(root.clone(), identity)?; + let existing = repository + .worktree_blob_map()? + .keys() + .map(PathBuf::from) + .collect::>(); + if !existing.is_empty() { + repository.stage(&existing)?; + repository.commit("Add current contents of password store.")?; + } + atomic_write_worktree(&root, Path::new(".gitattributes"), b"*.gpg diff=gpg\n")?; + repository.stage(&[PathBuf::from(".gitattributes")])?; + repository.commit("Configure git repository for gpg file diff.")?; + Ok(repository) + })(); + if initialized.is_err() { + match previous_attributes { + Some(contents) => { + let _ = atomic_write_worktree(&root, Path::new(".gitattributes"), &contents); + } + None => { + let _ = fs::remove_file(&attributes_path); + } + } + let _ = fs::remove_dir_all(root.join(".git")); + } + initialized + } + + pub fn open(store: &Repository, identity: GitIdentity) -> Result { + Self::open_at(store.root_path().to_owned(), identity) + } + + /// Open the innermost repository containing `path`, without crossing the store root. + pub fn open_innermost( + store: &Repository, + path: &Path, + identity: GitIdentity, + ) -> Result { + let relative = validate_relative(path)?; + let mut candidate = store.root_path().join(relative); + if !candidate.is_dir() { + candidate.pop(); + } + loop { + if candidate.join(".git").is_dir() { + return Self::open_at(candidate, identity); + } + if candidate == store.root_path() || !candidate.pop() { + return Err(GitError::NotRepository); + } + } + } + + fn open_at(root: PathBuf, identity: GitIdentity) -> Result { + let repository = + gix::open_opts(&root, isolated_options()).map_err(|_| GitError::NotRepository)?; + if repository.is_bare() { + return Err(GitError::BareRepository); + } + validate_local_config_security(&load_local_config(&repository)?)?; + Ok(Self { + root, + repository, + identity, + }) + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn remotes(&self) -> Vec { + self.repository + .remote_names() + .into_iter() + .map(|name| name.to_string()) + .collect() + } + + pub fn remote_url(&self, name: &str) -> Result { + validate_remote_name(name)?; + let remote = self + .repository + .find_remote(name) + .map_err(|_| GitError::RemoteNotFound { + name: name.to_owned(), + })?; + let url = + remote + .url(gix::remote::Direction::Fetch) + .ok_or_else(|| GitError::RemoteNotFound { + name: name.to_owned(), + })?; + let text = url.to_bstring().to_str_lossy().into_owned(); + validate_https_remote(&text)?; + Ok(text) + } + + pub fn add_remote(&mut self, name: &str, url: &str) -> Result<(), GitError> { + validate_remote_name(name)?; + validate_https_remote(url)?; + if self.remotes().iter().any(|existing| existing == name) { + return Err(GitError::InvalidRepository(format!( + "remote {name} already exists" + ))); + } + self.update_local_config(|config| { + config + .set_raw_value_by("remote", Some(name.into()), "url", url) + .map_err(invalid)?; + config + .set_raw_value_by( + "remote", + Some(name.into()), + "fetch", + format!("+refs/heads/*:refs/remotes/{name}/*"), + ) + .map_err(invalid)?; + Ok(()) + }) + } + + pub fn set_remote_url(&mut self, name: &str, url: &str) -> Result<(), GitError> { + validate_remote_name(name)?; + validate_https_remote(url)?; + if !self.remotes().iter().any(|existing| existing == name) { + return Err(GitError::RemoteNotFound { + name: name.to_owned(), + }); + } + self.update_local_config(|config| { + config + .set_raw_value_by("remote", Some(name.into()), "url", url) + .map_err(invalid)?; + Ok(()) + }) + } + + pub fn remove_remote(&mut self, name: &str) -> Result<(), GitError> { + validate_remote_name(name)?; + self.update_local_config(|config| { + if config.remove_section("remote", name).is_none() { + return Err(GitError::RemoteNotFound { + name: name.to_owned(), + }); + } + Ok(()) + }) + } + + pub fn config_get(&self, key: &str) -> Result, GitError> { + validate_config_key(key)?; + let config = load_local_config(&self.repository)?; + match config.raw_value(key) { + Ok(value) => Ok(Some(value.to_str_lossy().into_owned())), + Err(_) => Ok(None), + } + } + + pub fn config_set(&mut self, key: &str, value: &str) -> Result<(), GitError> { + validate_config_key(key)?; + let parts = key.split('.').collect::>(); + if parts + .first() + .is_some_and(|section| section.eq_ignore_ascii_case("remote")) + && parts + .last() + .is_some_and(|name| name.eq_ignore_ascii_case("url")) + { + if parts.len() < 3 { + return Err(GitError::InvalidRepository( + "invalid remote URL configuration key".to_owned(), + )); + } + validate_remote_name(&parts[1..parts.len() - 1].join("."))?; + validate_https_remote(value)?; + } + if value.contains(['\n', '\r', '\0']) { + return Err(GitError::InvalidRepository( + "invalid Git configuration value".to_owned(), + )); + } + self.update_local_config(|config| { + config.set_raw_value(key, value).map_err(invalid)?; + Ok(()) + }) + } + + pub fn fetch( + &self, + configured: &GitRemote, + credentials: &impl GitCredentialProvider, + ) -> Result { + self.fetch_with_transport(configured, credentials, &EmbeddedFetchTransport) + } + + pub fn fetch_with_transport( + &self, + configured: &GitRemote, + credentials: &impl GitCredentialProvider, + transport: &impl GitFetchTransport, + ) -> Result { + let name = configured.name().as_str(); + let actual_url = self.remote_url(name)?; + if actual_url != configured.url().as_str() { + return Err(GitError::ForbiddenRemoteUrl); + } + let credential = + credentials.credential(configured.server_id(), configured.application_id())?; + let received_pack = transport.fetch(self, configured, &credential)?; + Ok(FetchOutcome { + remote: name.to_owned(), + received_pack, + }) + } + + #[allow( + clippy::result_large_err, + reason = "the gix credential callback fixes its protocol error type" + )] + fn fetch_embedded( + &self, + configured: &GitRemote, + credential: &GitCredential, + ) -> Result { + let name = configured.name().as_str(); + let password = std::str::from_utf8(credential.password()) + .map_err(|_| GitError::CredentialsUnavailable)? + .to_owned(); + let username = credential.username().to_owned(); + let expected_origin = configured.url().clone(); + let remote = self + .repository + .find_fetch_remote(Some(name.into())) + .map_err(invalid)?; + let mut connection = remote + .connect(gix::remote::Direction::Fetch) + .map_err(invalid)?; + connection.set_credentials(move |action| match action { + gix::credentials::helper::Action::Get(context) => { + let same_origin = context + .url + .as_ref() + .and_then(|value| value.to_str().ok()) + .and_then(|value| url::Url::parse(value).ok()) + .is_some_and(|requested| same_https_origin(&expected_origin, &requested)); + if !same_origin { + return Err(gix::credentials::protocol::Error::Quit); + } + Ok(Some(gix::credentials::protocol::Outcome { + identity: gix::sec::identity::Account { + username: username.clone(), + password: password.clone(), + oauth_refresh_token: None, + }, + next: context.into(), + })) + } + gix::credentials::helper::Action::Store(_) + | gix::credentials::helper::Action::Erase(_) => Ok(None), + }); + let prepared = connection + .prepare_fetch(gix::progress::Discard, Default::default()) + .map_err(invalid)?; + let outcome = prepared + .receive( + gix::progress::Discard, + &std::sync::atomic::AtomicBool::new(false), + ) + .map_err(|error| { + let text = error.to_string(); + if text.contains("401") + || text.contains("403") + || text.to_ascii_lowercase().contains("authentication") + { + GitError::AuthenticationFailed + } else { + invalid(error) + } + })?; + Ok(matches!( + outcome.status, + gix::remote::fetch::Status::Change { .. } + )) + } + + pub fn pull( + &self, + configured: &GitRemote, + branch: Option<&str>, + credentials: &impl GitCredentialProvider, + ) -> Result { + self.pull_with_transport(configured, branch, credentials, &EmbeddedFetchTransport) + } + + pub fn pull_with_transport( + &self, + configured: &GitRemote, + branch: Option<&str>, + credentials: &impl GitCredentialProvider, + transport: &impl GitFetchTransport, + ) -> Result { + if !self.status()?.is_clean() { + return Err(GitError::DirtyWorktree); + } + self.fetch_with_transport(configured, credentials, transport)?; + self.integrate_fetched(configured, branch) + } + + /// Integrate an already fetched remote-tracking branch. This is exposed so + /// platform networking adapters and deterministic tests can separate the + /// transport step from merge/checkout policy. + pub fn integrate_fetched( + &self, + configured: &GitRemote, + branch: Option<&str>, + ) -> Result { + if !self.status()?.is_clean() { + return Err(GitError::DirtyWorktree); + } + let branch = branch.map_or_else( + || self.current_branch(), + |branch| { + validate_remote_name(branch)?; + Ok(branch.to_owned()) + }, + )?; + let remote_ref_name = format!("refs/remotes/{}/{branch}", configured.name()); + let remote_id = self + .repository + .find_reference(&remote_ref_name) + .map_err(|_| GitError::RemoteNotFound { + name: remote_ref_name.clone(), + })? + .into_fully_peeled_id() + .map_err(invalid)? + .detach(); + let local = self.repository.head_id().ok().map(|id| id.detach()); + let Some(local_id) = local else { + self.checkout_and_update(remote_id, None)?; + return Ok(PullOutcome::FastForward); + }; + if local_id == remote_id { + return Ok(PullOutcome::UpToDate); + } + let base = self + .repository + .merge_base(local_id, remote_id) + .map_err(invalid)? + .detach(); + if base == remote_id { + return Ok(PullOutcome::UpToDate); + } + if base == local_id { + self.checkout_and_update(remote_id, Some(local_id))?; + return Ok(PullOutcome::FastForward); + } + + let labels = gix::merge::blob::builtin_driver::text::Labels { + ancestor: Some("base".into()), + current: Some("HEAD".into()), + other: Some(configured.name().as_str().into()), + }; + let options = self + .repository + .tree_merge_options() + .map_err(invalid)? + .into(); + let mut outcome = self + .repository + .merge_commits(local_id, remote_id, labels, options) + .map_err(invalid)?; + let unresolved = gix::merge::tree::TreatAsUnresolved::default(); + if outcome.tree_merge.has_unresolved_conflicts(unresolved) { + let mut paths = outcome + .tree_merge + .conflicts + .iter() + .filter(|conflict| conflict.is_unresolved(unresolved)) + .map(|conflict| PathBuf::from(conflict.ours.location().to_str_lossy().as_ref())) + .collect::>(); + paths.sort(); + paths.dedup(); + return Err(GitError::MergeConflicts { paths }); + } + let tree = outcome.tree_merge.tree.write().map_err(invalid)?.detach(); + let signature = self.identity.signature(); + let mut committer_time = gix::date::parse::TimeBuf::default(); + let mut author_time = gix::date::parse::TimeBuf::default(); + let commit = self + .repository + .new_commit_as( + signature.to_ref(&mut committer_time), + signature.to_ref(&mut author_time), + format!( + "Merge remote-tracking branch '{}/{}'.", + configured.name(), + branch + ), + tree, + [local_id, remote_id], + ) + .map_err(invalid)?; + self.checkout_and_update(commit.id, Some(local_id))?; + Ok(PullOutcome::Merged) + } + + pub fn push( + &self, + configured: &GitRemote, + branch: Option<&str>, + credentials: &impl GitCredentialProvider, + ) -> Result { + self.push_with_transport(configured, branch, credentials, &ReqwestGitTransport) + } + + pub fn push_with_transport( + &self, + configured: &GitRemote, + branch: Option<&str>, + credentials: &impl GitCredentialProvider, + transport: &impl GitSmartHttpTransport, + ) -> Result { + if !self.status()?.is_clean() { + return Err(GitError::DirtyWorktree); + } + let name = configured.name().as_str(); + let actual_url = self.remote_url(name)?; + if actual_url != configured.url().as_str() { + return Err(GitError::ForbiddenRemoteUrl); + } + let url = validate_https_remote(&actual_url)?; + let branch = branch.map_or_else( + || self.current_branch(), + |branch| { + validate_remote_name(branch)?; + Ok(branch.to_owned()) + }, + )?; + let reference = format!("refs/heads/{branch}"); + let new = self + .repository + .head_id() + .map_err(|_| GitError::UnbornHead)? + .detach(); + let credential = + credentials.credential(configured.server_id(), configured.application_id())?; + let advertisement = transport.advertise_receive_pack(&url, &credential)?; + let advertised = parse_receive_pack_advertisement(&advertisement)?; + let old = advertised.refs.get(&reference).copied(); + if let Some(old) = old { + if old == new { + return Ok(PushOutcome { + remote: name.to_owned(), + branch, + old: Some(old.to_string()), + new: new.to_string(), + }); + } + let base = self + .repository + .merge_base(old, new) + .map_err(|_| GitError::NonFastForward)? + .detach(); + if base != old { + return Err(GitError::NonFastForward); + } + } + if !advertised.capabilities.contains("report-status") { + return Err(GitError::InvalidRepository( + "server does not support receive-pack status reports".to_owned(), + )); + } + let pack = build_pack(&self.repository, new, old)?; + let old_hex = old.map_or_else( + || "0000000000000000000000000000000000000000".to_owned(), + |id| id.to_string(), + ); + let command = format!( + "{old_hex} {new} {reference}\0report-status agent=ironstorage/{}\n", + env!("CARGO_PKG_VERSION") + ); + let mut request = encode_pkt_line(command.as_bytes())?; + request.extend_from_slice(b"0000"); + request.extend_from_slice(&pack); + let response = transport.receive_pack(&url, &credential, request)?; + parse_receive_pack_result(&response, &reference)?; + Ok(PushOutcome { + remote: name.to_owned(), + branch, + old: old.map(|id| id.to_string()), + new: new.to_string(), + }) + } + + pub fn sync( + &self, + configured: &GitRemote, + credentials: &impl GitCredentialProvider, + ) -> Result<(PullOutcome, PushOutcome), GitError> { + let pull = self.pull(configured, None, credentials)?; + let push = self.push(configured, None, credentials)?; + Ok((pull, push)) + } + + fn current_branch(&self) -> Result { + let name = self + .repository + .head_name() + .map_err(invalid)? + .ok_or_else(|| { + GitError::InvalidRepository("detached HEAD is unsupported".to_owned()) + })?; + name.as_bstr() + .strip_prefix(b"refs/heads/") + .and_then(|name| name.to_str().ok()) + .map(str::to_owned) + .ok_or_else(|| { + GitError::InvalidRepository("HEAD does not name a local branch".to_owned()) + }) + } + + fn checkout_and_update( + &self, + commit_id: gix::hash::ObjectId, + expected: Option, + ) -> Result<(), GitError> { + let target_tree = self + .repository + .find_object(commit_id) + .map_err(invalid)? + .peel_to_commit() + .map_err(invalid)? + .tree_id() + .map_err(invalid)? + .detach(); + let old_tree = expected + .map(|id| { + self.repository + .find_object(id) + .map_err(invalid)? + .peel_to_commit() + .map_err(invalid)? + .tree_id() + .map_err(invalid) + .map(|id| id.detach()) + }) + .transpose()?; + let previous_index = load_index(&self.repository)?; + apply_tree_to_worktree(&self.repository, &self.root, old_tree, target_tree)?; + let mut next_index = self + .repository + .index_from_tree(&target_tree) + .map_err(invalid)?; + if let Err(error) = next_index.write(Default::default()) { + if let Some(old_tree) = old_tree { + let _ = apply_tree_to_worktree( + &self.repository, + &self.root, + Some(target_tree), + old_tree, + ); + } + return Err(invalid(error)); + } + let update = self.update_head(commit_id, expected, "pull: update local branch"); + if let Err(error) = update { + let mut previous_index = previous_index; + let _ = previous_index.write(Default::default()); + if let Some(old_tree) = old_tree { + let _ = apply_tree_to_worktree( + &self.repository, + &self.root, + Some(target_tree), + old_tree, + ); + } + return Err(error); + } + Ok(()) + } + + fn update_head( + &self, + new: gix::hash::ObjectId, + expected: Option, + message: impl Into, + ) -> Result<(), GitError> { + use gix::refs::{ + Target, + transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}, + }; + + let name = self + .repository + .head_name() + .map_err(invalid)? + .ok_or_else(|| GitError::InvalidRepository("detached HEAD".to_owned()))?; + let expected = expected.map_or(PreviousValue::MustNotExist, |id| { + PreviousValue::MustExistAndMatch(Target::Object(id)) + }); + let edit = RefEdit { + change: Change::Update { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: message.into(), + }, + expected, + new: Target::Object(new), + }, + name, + deref: true, + }; + let signature = self.identity.signature(); + let mut time = gix::date::parse::TimeBuf::default(); + self.repository + .edit_references_as(Some(edit), Some(signature.to_ref(&mut time))) + .map_err(invalid)?; + Ok(()) + } + + fn update_local_config( + &mut self, + update: impl FnOnce(&mut gix_config::File) -> Result<(), GitError>, + ) -> Result<(), GitError> { + let mut config = load_local_config(&self.repository)?; + update(&mut config)?; + validate_local_config_security(&config)?; + write_local_config(&self.repository, &config)?; + self.repository = gix::open_opts(&self.root, isolated_options()).map_err(invalid)?; + Ok(()) + } + + pub fn stage(&self, paths: &[PathBuf]) -> Result<(), GitError> { + if paths.is_empty() { + return Err(GitError::InvalidPath { + path: PathBuf::new(), + }); + } + let mut selected = Vec::with_capacity(paths.len()); + for path in paths { + selected.push(validate_relative(path)?); + } + let index = load_index(&self.repository)?; + let mut editor = self + .repository + .edit_tree(self.repository.empty_tree().id) + .map_err(invalid)?; + let mut tracked = Vec::new(); + for entry in index.entries() { + let path = entry.path(&index); + let mode = entry.mode.to_tree_entry_mode().ok_or_else(|| { + GitError::InvalidRepository("the index contains an invalid entry mode".to_owned()) + })?; + editor + .upsert(path, mode.kind(), entry.id) + .map_err(invalid)?; + tracked.push(BString::from(path)); + } + + for path in &selected { + let path_bytes = path_to_git(path)?; + for tracked_path in &tracked { + if matches_path(tracked_path.as_slice(), &path_bytes) { + editor.remove(tracked_path.as_bstr()).map_err(invalid)?; + } + } + self.add_worktree_path(&mut editor, path)?; + } + let tree = editor.write().map_err(invalid)?; + let mut next = self.repository.index_from_tree(&tree).map_err(invalid)?; + next.write(Default::default()).map_err(invalid)?; + Ok(()) + } + + fn add_worktree_path( + &self, + editor: &mut gix::object::tree::Editor<'_>, + path: &Path, + ) -> Result<(), GitError> { + let absolute = self.root.join(path); + let metadata = match fs::symlink_metadata(&absolute) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(_) => return Err(io("inspect", path)), + }; + if metadata.file_type().is_symlink() || (!metadata.is_file() && !metadata.is_dir()) { + return Err(GitError::UnsafeWorktreeObject { + path: path.to_owned(), + }); + } + if metadata.is_file() { + if metadata.len() > MAX_FILE_BYTES { + return Err(GitError::FileTooLarge { + path: path.to_owned(), + }); + } + let contents = fs::read(&absolute).map_err(|_| io("read", path))?; + let id = self.repository.write_blob(contents).map_err(invalid)?; + editor + .upsert(BString::from(path_to_git(path)?), EntryKind::Blob, id) + .map_err(invalid)?; + return Ok(()); + } + let entries = fs::read_dir(&absolute).map_err(|_| io("read directory", path))?; + let mut children = entries + .collect::, _>>() + .map_err(|_| io("read directory", path))?; + children.sort_by_key(std::fs::DirEntry::file_name); + for child in children { + if child.file_name() == ".git" { + continue; + } + self.add_worktree_path(editor, &path.join(child.file_name()))?; + } + Ok(()) + } + + pub fn commit(&self, message: &str) -> Result { + let index = load_index(&self.repository)?; + let tree = tree_from_index(&self.repository, &index)?; + self.commit_tree(message, tree) + } + + fn commit_tree(&self, message: &str, tree: gix::hash::ObjectId) -> Result { + validate_commit_message(message)?; + let parent = self.repository.head_id().ok().map(|id| id.detach()); + if let Some(parent) = parent { + let parent_tree = self + .repository + .find_object(parent) + .map_err(invalid)? + .peel_to_commit() + .map_err(invalid)? + .tree_id() + .map_err(invalid)? + .detach(); + if parent_tree == tree { + return Err(GitError::NoChanges); + } + } + let signature = self.identity.signature(); + let mut committer_time = gix::date::parse::TimeBuf::default(); + let mut author_time = gix::date::parse::TimeBuf::default(); + let parents = parent.into_iter().collect::>(); + let id = self + .repository + .commit_as( + signature.to_ref(&mut committer_time), + signature.to_ref(&mut author_time), + "HEAD", + message, + tree, + parents, + ) + .map_err(invalid)?; + Ok(id.to_string()) + } + + pub fn commit_signed( + &self, + message: &str, + keys: &KeyStore, + signer: &KeyHandle, + secrets: &mut impl SecretProvider, + ) -> Result { + let index = load_index(&self.repository)?; + let tree = tree_from_index(&self.repository, &index)?; + self.commit_tree_signed(message, tree, keys, signer, secrets) + } + + fn commit_tree_signed( + &self, + message: &str, + tree: gix::hash::ObjectId, + keys: &KeyStore, + signer: &KeyHandle, + secrets: &mut impl SecretProvider, + ) -> Result { + validate_commit_message(message)?; + let parent = self.repository.head_id().ok().map(|id| id.detach()); + if let Some(parent) = parent { + let parent_tree = self + .repository + .find_object(parent) + .map_err(invalid)? + .peel_to_commit() + .map_err(invalid)? + .tree_id() + .map_err(invalid)? + .detach(); + if parent_tree == tree { + return Err(GitError::NoChanges); + } + } + let signature = self.identity.signature(); + let mut commit = gix::objs::Commit { + tree, + parents: parent.into_iter().collect(), + author: signature.clone(), + committer: signature, + encoding: None, + message: message.into(), + extra_headers: Vec::new(), + }; + let mut unsigned = Vec::new(); + gix::objs::WriteTo::write_to(&commit, &mut unsigned).map_err(invalid)?; + let detached = keys.sign(&unsigned, signer, secrets).map_err(invalid)?; + let armor = keys.armor_signature(&detached).map_err(invalid)?; + commit.extra_headers.push(("gpgsig".into(), armor.into())); + let id = self.repository.write_object(&commit).map_err(invalid)?; + self.update_head(id.detach(), parent, format!("commit: {message}"))?; + Ok(id.to_string()) + } + + pub fn stage_and_commit( + &self, + paths: &[PathBuf], + message: &str, + ) -> Result, GitError> { + let (previous, tree) = self.prepare_automatic_commit(paths)?; + match self.commit_tree(message, tree) { + Ok(id) => Ok(Some(id)), + Err(GitError::NoChanges) => { + restore_index(previous)?; + Ok(None) + } + Err(error) => { + restore_index(previous)?; + Err(error) + } + } + } + + pub fn stage_and_commit_signed( + &self, + paths: &[PathBuf], + message: &str, + keys: &KeyStore, + signer: &KeyHandle, + secrets: &mut impl SecretProvider, + ) -> Result, GitError> { + let (previous, tree) = self.prepare_automatic_commit(paths)?; + match self.commit_tree_signed(message, tree, keys, signer, secrets) { + Ok(id) => Ok(Some(id)), + Err(GitError::NoChanges) => { + restore_index(previous)?; + Ok(None) + } + Err(error) => { + restore_index(previous)?; + Err(error) + } + } + } + + fn prepare_automatic_commit( + &self, + paths: &[PathBuf], + ) -> Result<(gix::index::File, gix::hash::ObjectId), GitError> { + let selected = paths + .iter() + .map(|path| path_to_git(path)) + .collect::, _>>()?; + if selected.is_empty() { + return Err(GitError::InvalidPath { + path: PathBuf::new(), + }); + } + let previous = load_index(&self.repository)?; + let prepared = (|| { + let head_tree = self + .repository + .head_commit() + .ok() + .map(|commit| commit.tree_id().map(|id| id.detach())) + .transpose() + .map_err(invalid)?; + let base_tree = head_tree.unwrap_or_else(|| self.repository.empty_tree().id); + let mut base_index = self + .repository + .index_from_tree(&base_tree) + .map_err(invalid)?; + base_index.write(Default::default()).map_err(invalid)?; + + self.stage(paths)?; + let selected_index = load_index(&self.repository)?; + let commit_tree = tree_from_index(&self.repository, &selected_index)?; + + let head = self.head_tree_map()?.unwrap_or_default(); + let prior = index_map(&previous)?; + let mut editor = self.repository.edit_tree(commit_tree).map_err(invalid)?; + let paths = head + .keys() + .chain(prior.keys()) + .cloned() + .collect::>(); + for path in paths { + if head.get(&path) == prior.get(&path) + || selected + .iter() + .any(|selected| matches_path(path.as_bytes(), selected)) + { + continue; + } + editor.remove(path.as_bytes().as_bstr()).map_err(invalid)?; + if let Some(entry) = previous.entry_by_path(path.as_bytes().as_bstr()) { + let mode = entry.mode.to_tree_entry_mode().ok_or_else(|| { + GitError::InvalidRepository( + "the index contains an invalid entry mode".to_owned(), + ) + })?; + editor + .upsert(path.as_bytes().as_bstr(), mode.kind(), entry.id) + .map_err(invalid)?; + } + } + let retained_tree = editor.write().map_err(invalid)?; + let mut retained_index = self + .repository + .index_from_tree(&retained_tree) + .map_err(invalid)?; + retained_index.write(Default::default()).map_err(invalid)?; + Ok(commit_tree) + })(); + match prepared { + Ok(tree) => Ok((previous, tree)), + Err(error) => { + restore_index(previous)?; + Err(error) + } + } + } + + pub fn status(&self) -> Result { + let index = load_index(&self.repository)?; + let index_map = index_map(&index)?; + let head_map = self.head_tree_map()?.unwrap_or_default(); + let worktree_map = self.worktree_map()?; + Ok(GitStatus { + staged: compare_maps(&head_map, &index_map), + unstaged: compare_maps(&index_map, &worktree_map), + }) + } + + pub fn log(&self, maximum: Option) -> Result, GitError> { + let head = self + .repository + .head_commit() + .map_err(|_| GitError::UnbornHead)?; + let mut output = Vec::new(); + for info in head.ancestors().all().map_err(invalid)? { + if maximum.is_some_and(|maximum| output.len() >= maximum) { + break; + } + let info = info.map_err(invalid)?; + let commit = info.object().map_err(invalid)?; + let decoded = commit.decode().map_err(invalid)?; + let author = decoded.author().map_err(invalid)?; + output.push(GitLogEntry { + id: commit.id.to_string(), + parents: decoded.parents().map(|id| id.to_string()).collect(), + author_name: author.name.to_str_lossy().into_owned(), + author_email: author.email.to_str_lossy().into_owned(), + message: decoded.message.to_str_lossy().into_owned(), + timestamp: author.time().map_err(invalid)?.seconds, + }); + } + Ok(output) + } + + pub fn diff(&self, paths: &[PathBuf]) -> Result, GitError> { + let head = self.head_blob_map()?.unwrap_or_default(); + let worktree = self.worktree_blob_map()?; + let filter = paths + .iter() + .map(|p| validate_relative(p)) + .collect::, _>>()?; + let mut names = head + .keys() + .chain(worktree.keys()) + .cloned() + .collect::>(); + let mut output = Vec::new(); + for name in std::mem::take(&mut names) { + if !filter.is_empty() + && !filter.iter().any(|path| { + matches_path(name.as_bytes(), &path_to_git(path).unwrap_or_default()) + }) + { + continue; + } + let old = head.get(&name).cloned(); + let new = worktree.get(&name).cloned(); + if old != new { + output.push(GitDiffEntry { + path: PathBuf::from(&name), + old, + new, + }); + } + } + Ok(output) + } + + fn head_tree_map(&self) -> Result>, GitError> { + let Some(id) = self.repository.head_id().ok() else { + return Ok(None); + }; + let tree = id + .object() + .map_err(invalid)? + .peel_to_commit() + .map_err(invalid)? + .tree() + .map_err(invalid)?; + tree_map(tree).map(Some) + } + + fn head_blob_map(&self) -> Result>>, GitError> { + let Some(ids) = self.head_tree_map()? else { + return Ok(None); + }; + let mut output = BTreeMap::new(); + for (path, id) in ids { + output.insert( + path, + self.repository + .find_object(id) + .map_err(invalid)? + .into_blob() + .data + .clone(), + ); + } + Ok(Some(output)) + } + + fn worktree_map(&self) -> Result, GitError> { + let mut output = BTreeMap::new(); + for (path, bytes) in self.worktree_blob_map()? { + output.insert( + path, + gix::objs::compute_hash(gix::hash::Kind::Sha1, gix::objs::Kind::Blob, &bytes) + .map_err(invalid)?, + ); + } + Ok(output) + } + + fn worktree_blob_map(&self) -> Result>, GitError> { + let mut output = BTreeMap::new(); + collect_worktree(&self.root, Path::new(""), &mut output)?; + Ok(output) + } +} + +impl EntryCommitter for GitRepository { + fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> { + self.stage_and_commit(&[change.path().encrypted_relative_path()], change.message()) + .map(|_| ()) + .map_err(|error| EntryCommitError::new(error.to_string())) + } +} + +impl PolicyCommitter for GitRepository { + fn commit(&mut self, change: &PolicyCommit) -> Result<(), PolicyCommitError> { + self.stage_and_commit(&change.changed_paths(), change.message()) + .map(|_| ()) + .map_err(|error| PolicyCommitError::new(error.to_string())) + } +} + +impl TreeCommitter for GitRepository { + fn commit(&mut self, change: &TreeCommit) -> Result<(), TreeCommitError> { + self.stage_and_commit(change.changed_paths(), change.message()) + .map(|_| ()) + .map_err(|error| TreeCommitError::new(error.to_string())) + } +} + +fn isolated_options() -> gix::open::Options { + gix::open::Options::isolated().config_overrides([ + format!("init.defaultBranch={DEFAULT_BRANCH}"), + "core.autocrlf=false".to_owned(), + "core.symlinks=false".to_owned(), + ]) +} + +fn load_index(repository: &gix::Repository) -> Result { + if repository.index_path().exists() { + return repository.open_index().map_err(invalid); + } + match repository.head_commit() { + Ok(commit) => { + let tree = commit.tree_id().map_err(invalid)?; + repository.index_from_tree(&tree).map_err(invalid) + } + Err(_) => Ok(gix::index::File::from_state( + gix::index::State::new(repository.object_hash()), + repository.index_path(), + )), + } +} + +fn tree_from_index( + repository: &gix::Repository, + index: &gix::index::File, +) -> Result { + let mut editor = repository + .edit_tree(repository.empty_tree().id) + .map_err(invalid)?; + for entry in index.entries() { + let mode = entry + .mode + .to_tree_entry_mode() + .ok_or_else(|| GitError::InvalidRepository("invalid index entry mode".to_owned()))?; + editor + .upsert(entry.path(index), mode.kind(), entry.id) + .map_err(invalid)?; + } + Ok(editor.write().map_err(invalid)?.detach()) +} + +fn index_map(index: &gix::index::File) -> Result, GitError> { + index + .entries() + .iter() + .map(|entry| { + let path = entry.path(index).to_str().map_err(|_| { + GitError::InvalidRepository("non-UTF-8 Git paths are unsupported".to_owned()) + })?; + Ok((path.to_owned(), entry.id)) + }) + .collect() +} + +fn tree_map(tree: gix::Tree<'_>) -> Result, GitError> { + let mut output = BTreeMap::new(); + let mut recorder = gix::traverse::tree::Recorder::default(); + tree.traverse() + .breadthfirst(&mut recorder) + .map_err(invalid)?; + for record in recorder.records { + if record.mode.is_blob() { + let path = record.filepath.to_str().map_err(|_| { + GitError::InvalidRepository("non-UTF-8 Git paths are unsupported".to_owned()) + })?; + output.insert(path.to_owned(), record.oid); + } else if !record.mode.is_tree() { + return Err(GitError::UnsafeWorktreeObject { + path: PathBuf::from(record.filepath.to_str_lossy().as_ref()), + }); + } + } + Ok(output) +} + +fn tree_map_by_id( + repository: &gix::Repository, + tree: Option, +) -> Result, GitError> { + match tree { + Some(tree) => tree_map(repository.find_object(tree).map_err(invalid)?.into_tree()), + None => Ok(BTreeMap::new()), + } +} + +fn apply_tree_to_worktree( + repository: &gix::Repository, + root: &Path, + old_tree: Option, + new_tree: gix::hash::ObjectId, +) -> Result<(), GitError> { + let old = tree_map_by_id(repository, old_tree)?; + let new = tree_map_by_id(repository, Some(new_tree))?; + let changed = old + .keys() + .chain(new.keys()) + .filter(|path| old.get(*path) != new.get(*path)) + .cloned() + .collect::>(); + let mut replacement = BTreeMap::new(); + let mut backup = BTreeMap::new(); + for path in &changed { + let relative = validate_relative(Path::new(path))?; + let absolute = root.join(&relative); + backup.insert( + path.clone(), + match fs::symlink_metadata(&absolute) { + Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => { + Some(fs::read(&absolute).map_err(|_| io("read", &relative))?) + } + Ok(_) => return Err(GitError::UnsafeWorktreeObject { path: relative }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(_) => return Err(io("inspect", &relative)), + }, + ); + if let Some(id) = new.get(path) { + let bytes = repository + .find_object(*id) + .map_err(invalid)? + .into_blob() + .data + .clone(); + if bytes.len() as u64 > MAX_FILE_BYTES { + return Err(GitError::FileTooLarge { path: relative }); + } + replacement.insert(path.clone(), bytes); + } + } + let apply = (|| { + for path in &changed { + let relative = Path::new(path); + match replacement.get(path) { + Some(bytes) => atomic_write_worktree(root, relative, bytes)?, + None => { + let absolute = root.join(relative); + match fs::remove_file(&absolute) { + Ok(()) => cleanup_worktree_parents(root, relative), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(io("remove", relative)), + } + } + } + } + Ok(()) + })(); + if let Err(error) = apply { + for (path, contents) in backup { + match contents { + Some(contents) => { + let _ = atomic_write_worktree(root, Path::new(&path), &contents); + } + None => { + let _ = fs::remove_file(root.join(&path)); + } + } + } + return Err(error); + } + Ok(()) +} + +fn atomic_write_worktree(root: &Path, relative: &Path, contents: &[u8]) -> Result<(), GitError> { + use std::io::Write as _; + + let relative = validate_relative(relative)?; + let parent = relative.parent().unwrap_or(Path::new("")); + ensure_worktree_directory(root, parent)?; + let absolute = root.join(&relative); + let file_name = relative.file_name().ok_or_else(|| GitError::InvalidPath { + path: relative.clone(), + })?; + let mut temporary = None; + for attempt in 0..128_u8 { + let name = format!( + ".{}.ironstorage-{}-{attempt}", + file_name.to_string_lossy(), + std::process::id() + ); + let path = root.join(parent).join(name); + match fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&path) + { + Ok(file) => { + temporary = Some((path, file)); + break; + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(_) => return Err(io("create temporary file for", &relative)), + } + } + let (temporary_path, mut file) = + temporary.ok_or_else(|| io("create temporary file for", &relative))?; + let result = (|| { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|_| io("set permissions on", &relative))?; + } + file.write_all(contents) + .map_err(|_| io("write", &relative))?; + file.flush().map_err(|_| io("flush", &relative))?; + file.sync_all().map_err(|_| io("synchronize", &relative))?; + drop(file); + fs::rename(&temporary_path, &absolute).map_err(|_| io("replace", &relative))?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(temporary_path); + } + result +} + +fn ensure_worktree_directory(root: &Path, relative: &Path) -> Result<(), GitError> { + let mut current = root.to_owned(); + for component in relative.components() { + let Component::Normal(name) = component else { + return Err(GitError::InvalidPath { + path: relative.to_owned(), + }); + }; + current.push(name); + match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {} + Ok(_) => { + return Err(GitError::UnsafeWorktreeObject { + path: current.strip_prefix(root).unwrap_or(¤t).to_owned(), + }); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + fs::create_dir(¤t).map_err(|_| io("create directory", relative))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(¤t, fs::Permissions::from_mode(0o700)) + .map_err(|_| io("set directory permissions", relative))?; + } + } + Err(_) => return Err(io("inspect directory", relative)), + } + } + Ok(()) +} + +fn cleanup_worktree_parents(root: &Path, relative: &Path) { + let mut current = root.join(relative).parent().map(Path::to_owned); + while let Some(directory) = current { + if directory == root { + break; + } + if fs::remove_dir(&directory).is_err() { + break; + } + current = directory.parent().map(Path::to_owned); + } +} + +struct ReceivePackAdvertisement { + refs: BTreeMap, + capabilities: BTreeSet, +} + +fn parse_receive_pack_advertisement(input: &[u8]) -> Result { + let packets = decode_pkt_lines(input)?; + let mut refs = BTreeMap::new(); + let mut capabilities = BTreeSet::new(); + let mut first_ref = true; + for packet in packets.into_iter().flatten() { + let line = packet.strip_suffix(b"\n").unwrap_or(&packet); + if line.starts_with(b"# service=") || line.is_empty() { + continue; + } + let (reference, caps) = if first_ref { + match line.split_once_str(b"\0") { + Some((reference, caps)) => (reference, Some(caps)), + None => (line, None), + } + } else { + (line, None) + }; + first_ref = false; + let Some(space) = reference.find_byte(b' ') else { + return Err(GitError::InvalidRepository( + "malformed receive-pack advertisement".to_owned(), + )); + }; + let id = &reference[..space]; + let name = &reference[space + 1..]; + if id == b"0000000000000000000000000000000000000000" && name == b"capabilities^{}" { + // Empty repositories advertise capabilities in a pseudo-ref. + } else { + let id = gix::hash::ObjectId::from_hex(id).map_err(invalid)?; + let name = + std::str::from_utf8(name).map_err(|_| invalid("non-UTF-8 reference name"))?; + if !name.ends_with("^{}") { + refs.insert(name.to_owned(), id); + } + } + if let Some(caps) = caps { + capabilities.extend( + caps.split(|byte| *byte == b' ') + .filter(|capability| !capability.is_empty()) + .map(|capability| { + String::from_utf8_lossy(capability) + .split('=') + .next() + .unwrap_or_default() + .to_owned() + }), + ); + } + } + if capabilities.is_empty() { + return Err(GitError::InvalidRepository( + "receive-pack advertisement has no capabilities".to_owned(), + )); + } + Ok(ReceivePackAdvertisement { refs, capabilities }) +} + +fn parse_receive_pack_result(input: &[u8], reference: &str) -> Result<(), GitError> { + let packets = decode_pkt_lines(input)?; + let mut unpacked = false; + let mut updated = false; + for packet in packets.into_iter().flatten() { + let line = packet.strip_prefix(b"\x01").unwrap_or(&packet); + for line in line + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()) + { + if line == b"unpack ok" { + unpacked = true; + } else if let Some(reason) = line.strip_prefix(b"unpack ") { + return Err(GitError::InvalidRepository(format!( + "remote could not unpack objects: {}", + String::from_utf8_lossy(reason) + ))); + } else if line == format!("ok {reference}").as_bytes() { + updated = true; + } else if let Some(reason) = line.strip_prefix(format!("ng {reference} ").as_bytes()) { + let reason = String::from_utf8_lossy(reason); + return if reason.to_ascii_lowercase().contains("non-fast-forward") { + Err(GitError::NonFastForward) + } else { + Err(GitError::InvalidRepository(format!( + "remote rejected update: {reason}" + ))) + }; + } + } + } + if !unpacked || !updated { + return Err(GitError::InvalidRepository( + "receive-pack response omitted update status".to_owned(), + )); + } + Ok(()) +} + +fn decode_pkt_lines(input: &[u8]) -> Result>>, GitError> { + if input.len() > 128 * 1024 * 1024 { + return Err(GitError::InvalidRepository( + "Git protocol response is too large".to_owned(), + )); + } + let mut cursor = 0; + let mut packets = Vec::new(); + while cursor < input.len() { + if input.len() - cursor < 4 { + return Err(GitError::InvalidRepository( + "truncated Git packet line".to_owned(), + )); + } + let length = std::str::from_utf8(&input[cursor..cursor + 4]) + .ok() + .and_then(|value| usize::from_str_radix(value, 16).ok()) + .ok_or_else(|| GitError::InvalidRepository("invalid Git packet length".to_owned()))?; + cursor += 4; + if length == 0 || length == 1 || length == 2 { + packets.push(None); + continue; + } + if !(4..=65_520).contains(&length) || cursor + length - 4 > input.len() { + return Err(GitError::InvalidRepository( + "invalid Git packet line".to_owned(), + )); + } + let end = cursor + length - 4; + packets.push(Some(input[cursor..end].to_vec())); + cursor = end; + if packets.len() > 1_000_000 { + return Err(GitError::InvalidRepository( + "too many Git packet lines".to_owned(), + )); + } + } + Ok(packets) +} + +fn encode_pkt_line(data: &[u8]) -> Result, GitError> { + let length = data + .len() + .checked_add(4) + .filter(|length| *length <= 65_520) + .ok_or_else(|| GitError::InvalidRepository("Git packet line is too large".to_owned()))?; + let mut output = format!("{length:04x}").into_bytes(); + output.extend_from_slice(data); + Ok(output) +} + +fn build_pack( + repository: &gix::Repository, + tip: gix::hash::ObjectId, + remote_tip: Option, +) -> Result, GitError> { + let excluded = match remote_tip { + Some(id) => reachable_objects(repository, id, &BTreeSet::new())?, + None => BTreeSet::new(), + }; + let objects = reachable_objects(repository, tip, &excluded)?; + let count = + u32::try_from(objects.len()).map_err(|_| invalid("too many objects for a Git pack"))?; + let mut pack = Vec::new(); + pack.extend_from_slice(b"PACK"); + pack.extend_from_slice(&2_u32.to_be_bytes()); + pack.extend_from_slice(&count.to_be_bytes()); + for id in objects { + let object = repository.find_object(id).map_err(invalid)?; + encode_pack_object_header(&mut pack, object.kind, object.data.len())?; + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + use std::io::Write as _; + encoder.write_all(&object.data).map_err(invalid)?; + pack.extend_from_slice(&encoder.finish().map_err(invalid)?); + } + let checksum = Sha1::digest(&pack); + pack.extend_from_slice(&checksum); + Ok(pack) +} + +fn reachable_objects( + repository: &gix::Repository, + tip: gix::hash::ObjectId, + excluded: &BTreeSet, +) -> Result, GitError> { + let mut pending = vec![tip]; + let mut found = BTreeSet::new(); + while let Some(id) = pending.pop() { + if excluded.contains(&id) || !found.insert(id) { + continue; + } + let object = repository.find_object(id).map_err(invalid)?; + match object.kind { + gix::objs::Kind::Commit => { + let commit = object.into_commit(); + pending.push(commit.tree_id().map_err(invalid)?.detach()); + pending.extend(commit.parent_ids().map(|id| id.detach())); + } + gix::objs::Kind::Tree => { + pending.extend( + object + .into_tree() + .decode() + .map_err(invalid)? + .entries + .into_iter() + .map(|entry| entry.oid.to_owned()), + ); + } + gix::objs::Kind::Tag => { + pending.push(object.into_tag().target_id().map_err(invalid)?.detach()) + } + gix::objs::Kind::Blob => {} + } + if found.len() > 10_000_000 { + return Err(GitError::InvalidRepository( + "repository has too many reachable objects".to_owned(), + )); + } + } + Ok(found) +} + +fn encode_pack_object_header( + output: &mut Vec, + kind: gix::objs::Kind, + size: usize, +) -> Result<(), GitError> { + let object_type = match kind { + gix::objs::Kind::Commit => 1_u8, + gix::objs::Kind::Tree => 2, + gix::objs::Kind::Blob => 3, + gix::objs::Kind::Tag => 4, + }; + let mut remaining = u64::try_from(size).map_err(|_| invalid("Git object is too large"))?; + let mut byte = (object_type << 4) | (remaining as u8 & 0x0f); + remaining >>= 4; + if remaining != 0 { + byte |= 0x80; + } + output.push(byte); + while remaining != 0 { + let mut byte = remaining as u8 & 0x7f; + remaining >>= 7; + if remaining != 0 { + byte |= 0x80; + } + output.push(byte); + } + Ok(()) +} + +fn compare_maps( + before: &BTreeMap, + after: &BTreeMap, +) -> Vec { + before + .keys() + .chain(after.keys()) + .collect::>() + .into_iter() + .filter_map(|path| { + let kind = match (before.get(path), after.get(path)) { + (None, Some(_)) => GitChangeKind::Added, + (Some(_), None) => GitChangeKind::Deleted, + (Some(old), Some(new)) if old != new => GitChangeKind::Modified, + _ => return None, + }; + Some(GitChange { + path: PathBuf::from(path), + kind, + }) + }) + .collect() +} + +fn collect_worktree( + root: &Path, + relative: &Path, + output: &mut BTreeMap>, +) -> Result<(), GitError> { + let absolute = root.join(relative); + let mut entries = fs::read_dir(&absolute) + .map_err(|_| io("read directory", relative))? + .collect::, _>>() + .map_err(|_| io("read directory", relative))?; + entries.sort_by_key(std::fs::DirEntry::file_name); + for entry in entries { + if relative.as_os_str().is_empty() && entry.file_name() == ".git" { + continue; + } + let path = relative.join(entry.file_name()); + let metadata = fs::symlink_metadata(entry.path()).map_err(|_| io("inspect", &path))?; + if metadata.file_type().is_symlink() || (!metadata.is_file() && !metadata.is_dir()) { + return Err(GitError::UnsafeWorktreeObject { path }); + } + if metadata.is_dir() { + if entry.path().join(".git").exists() { + continue; + } + collect_worktree(root, &path, output)?; + } else { + if metadata.len() > MAX_FILE_BYTES { + return Err(GitError::FileTooLarge { path }); + } + let name = path_to_git(&path)?; + let name = std::str::from_utf8(&name) + .map_err(|_| GitError::InvalidPath { path: path.clone() })? + .to_owned(); + output.insert(name, fs::read(entry.path()).map_err(|_| io("read", &path))?); + } + } + Ok(()) +} + +fn validate_relative(path: &Path) -> Result { + if path.as_os_str().is_empty() || path.is_absolute() { + return Err(GitError::InvalidPath { + path: path.to_owned(), + }); + } + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(name) if name != ".git" => normalized.push(name), + _ => { + return Err(GitError::InvalidPath { + path: path.to_owned(), + }); + } + } + } + Ok(normalized) +} + +fn validate_remote_name(name: &str) -> Result<(), GitError> { + if name.is_empty() + || name.starts_with('-') + || name.contains(['\0', '\n', '\r', ' ', '\\', '~', '^', ':', '?', '*', '[']) + || name.contains("..") + || name.contains("@{") + || name.ends_with('.') + || name.ends_with('/') + || name.starts_with('/') + || name + .split('/') + .any(|part| part.is_empty() || part.ends_with(".lock")) + { + return Err(GitError::InvalidRemoteName); + } + Ok(()) +} + +fn validate_https_remote(value: &str) -> Result { + let parsed = url::Url::parse(value).map_err(|_| GitError::ForbiddenRemoteUrl)?; + if parsed.scheme() != "https" + || parsed.host_str().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(GitError::ForbiddenRemoteUrl); + } + Ok(parsed) +} + +fn same_https_origin(expected: &url::Url, requested: &url::Url) -> bool { + expected.scheme() == "https" + && requested.scheme() == "https" + && expected.host_str() == requested.host_str() + && expected.port_or_known_default() == requested.port_or_known_default() +} + +fn validate_config_key(key: &str) -> Result<(), GitError> { + let mut parts = key.split('.'); + let section = parts.next().unwrap_or_default(); + let rest = parts.collect::>(); + if section.is_empty() + || rest.is_empty() + || key.contains(['\0', '\n', '\r', ' ', '=']) + || key.starts_with('.') + || key.ends_with('.') + || rest.iter().any(|part| part.is_empty()) + { + return Err(GitError::InvalidRepository( + "invalid Git configuration key".to_owned(), + )); + } + // These keys can make otherwise innocent library operations execute a + // program or read configuration outside the repository. They are never + // meaningful to an IronStorage frontend. + let lower = key.to_ascii_lowercase(); + let section = lower.split('.').next().unwrap_or_default(); + if lower == "core.hookspath" + || lower == "core.sshcommand" + || lower == "core.gitproxy" + || lower == "core.fsmonitor" + || lower.ends_with(".pushurl") + || matches!( + section, + "include" + | "includeif" + | "credential" + | "filter" + | "diff" + | "merge" + | "protocol" + | "url" + ) + { + return Err(GitError::InvalidRepository( + "unsafe Git configuration key".to_owned(), + )); + } + Ok(()) +} + +fn validate_local_config_security(config: &gix_config::File) -> Result<(), GitError> { + for section in config.sections() { + let name = section.header().name().to_str_lossy().to_ascii_lowercase(); + if matches!( + name.as_str(), + "include" + | "includeif" + | "credential" + | "filter" + | "diff" + | "merge" + | "protocol" + | "url" + ) { + return Err(GitError::InvalidRepository(format!( + "unsafe Git configuration section: {name}" + ))); + } + } + for key in [ + "core.hooksPath", + "core.sshCommand", + "core.gitProxy", + "core.fsmonitor", + ] { + if config.raw_value(key).is_ok() { + return Err(GitError::InvalidRepository(format!( + "unsafe Git configuration key: {key}" + ))); + } + } + if let Some(remotes) = config.sections_by_name("remote") { + for remote in remotes { + let name = remote + .header() + .subsection_name() + .ok_or(GitError::InvalidRemoteName)? + .to_str() + .map_err(|_| GitError::InvalidRemoteName)?; + validate_remote_name(name)?; + if config + .raw_value_by("remote", Some(name.into()), "pushurl") + .is_ok() + { + return Err(GitError::InvalidRepository( + "separate Git push URLs are unsupported".to_owned(), + )); + } + if let Ok(url) = config.raw_value_by("remote", Some(name.into()), "url") { + validate_https_remote(url.to_str().map_err(|_| GitError::ForbiddenRemoteUrl)?)?; + } + } + } + Ok(()) +} + +fn load_local_config(repository: &gix::Repository) -> Result { + gix_config::File::from_path_no_includes( + repository.git_dir().join("config"), + gix_config::Source::Local, + ) + .map_err(invalid) +} + +fn write_local_config( + repository: &gix::Repository, + config: &gix_config::File, +) -> Result<(), GitError> { + use std::io::Write as _; + + let path = repository.git_dir().join("config"); + let lock_path = repository.git_dir().join("config.lock"); + let mut lock = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&lock_path) + .map_err(|_| io("lock", &lock_path))?; + let result = (|| { + config.write_to(&mut lock).map_err(invalid)?; + lock.flush().map_err(|_| io("flush", &lock_path))?; + lock.sync_all().map_err(|_| io("synchronize", &lock_path))?; + fs::rename(&lock_path, &path).map_err(|_| io("replace", &path))?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&lock_path); + } + result +} + +fn path_to_git(path: &Path) -> Result, GitError> { + let normalized = validate_relative(path)?; + let text = normalized.to_str().ok_or_else(|| GitError::InvalidPath { + path: path.to_owned(), + })?; + Ok(text.replace(std::path::MAIN_SEPARATOR, "/").into_bytes()) +} + +fn matches_path(candidate: &[u8], selected: &[u8]) -> bool { + candidate == selected + || candidate + .strip_prefix(selected) + .is_some_and(|suffix| suffix.starts_with(b"/")) +} + +fn validate_commit_message(message: &str) -> Result<(), GitError> { + if message.trim().is_empty() || message.contains('\0') { + return Err(GitError::InvalidRepository( + "commit messages must not be empty".to_owned(), + )); + } + Ok(()) +} + +fn restore_index(mut index: gix::index::File) -> Result<(), GitError> { + index.write(Default::default()).map_err(invalid) +} + +fn invalid(error: impl fmt::Display) -> GitError { + GitError::InvalidRepository(error.to_string()) +} + +fn io(operation: &'static str, path: &Path) -> GitError { + GitError::Io { + operation, + path: path.to_owned(), + } +} diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 6cd2170..20c8c06 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -9,6 +9,7 @@ pub mod command; pub mod config; pub mod crypto; pub mod generate; +pub mod git; pub mod mutation; pub mod read; pub mod recipient; diff --git a/crates/storage/src/mutation.rs b/crates/storage/src/mutation.rs index 3f02ed5..0d37bd6 100644 --- a/crates/storage/src/mutation.rs +++ b/crates/storage/src/mutation.rs @@ -24,6 +24,7 @@ pub struct TreeCommit { action: MutationAction, source: String, destination: Option, + changed_paths: Vec, message: String, } @@ -37,6 +38,9 @@ impl TreeCommit { pub fn destination(&self) -> Option<&str> { self.destination.as_deref() } + pub fn changed_paths(&self) -> &[std::path::PathBuf] { + &self.changed_paths + } pub fn message(&self) -> &str { &self.message } @@ -148,6 +152,16 @@ impl<'a> TreeMutator<'a> { action: MutationAction::Remove, source: display.clone(), destination: None, + changed_paths: entries + .iter() + .map(|entry| entry.source.encrypted_relative_path()) + .chain(policies.iter().flat_map(|policy| { + [ + policy.directory.as_path().join(".gpg-id"), + policy.directory.as_path().join(".gpg-id.sig"), + ] + })) + .collect(), message: format!("Remove {display} from store."), }; if let Err(error) = committer.commit(&change) { @@ -452,10 +466,34 @@ impl<'a> TreeMutator<'a> { MutationAction::Copy }; let verb = if moving { "Rename" } else { "Copy" }; + let mut changed_paths = entries + .iter() + .map(|entry| entry.destination.encrypted_relative_path()) + .chain(policies.iter().flat_map(|policy| { + [ + policy.destination.as_path().join(".gpg-id"), + policy.destination.as_path().join(".gpg-id.sig"), + ] + })) + .collect::>(); + if moving { + changed_paths.extend( + source_entries + .iter() + .map(|entry| entry.source.encrypted_relative_path()), + ); + changed_paths.extend(source_policies.iter().flat_map(|policy| { + [ + policy.directory.as_path().join(".gpg-id"), + policy.directory.as_path().join(".gpg-id.sig"), + ] + })); + } let change = TreeCommit { action, source: source.to_owned(), destination: Some(destination.to_owned()), + changed_paths, message: format!("{verb} {source} to {destination}."), }; if let Err(error) = committer.commit(&change) { diff --git a/crates/storage/src/repository.rs b/crates/storage/src/repository.rs index b9cded9..f344082 100644 --- a/crates/storage/src/repository.rs +++ b/crates/storage/src/repository.rs @@ -45,7 +45,7 @@ impl EntryPath { DirectoryPath(self.0.parent().map_or_else(PathBuf::new, Path::to_path_buf)) } - fn encrypted_relative_path(&self) -> PathBuf { + pub fn encrypted_relative_path(&self) -> PathBuf { let mut path = self.0.clone(); let mut file_name = path .file_name() diff --git a/crates/storage/tests/git_embedded.rs b/crates/storage/tests/git_embedded.rs new file mode 100644 index 0000000..d92a38b --- /dev/null +++ b/crates/storage/tests/git_embedded.rs @@ -0,0 +1,558 @@ +#![forbid(unsafe_code)] + +mod support; + +use std::{error::Error, fs, io::Cursor, path::Path, sync::Mutex}; + +use ironstorage::{ + command::{InsertInput, InsertRequest}, + config::{Config, ConfigLoader, GitRemote}, + crypto::{DetachedSignatureBytes, KeyInfo, KeyStore, SecretProvider, SecretProviderError}, + git::{ + GitChangeKind, GitCredential, GitCredentialProvider, GitError, GitFetchTransport, + GitIdentity, GitRepository, GitSmartHttpTransport, PullOutcome, + }, + repository::{Repository, SecretBytes}, + write::{InsertContent, OverwriteDecision, VaultWriter}, +}; +use pgp::{ + composed::{Deserializable as _, DetachedSignature}, + ser::Serialize as _, +}; +use sha1::Digest as _; +use support::compatibility::FixtureSet; + +type TestResult = Result<(), Box>; + +fn identity() -> GitIdentity { + GitIdentity::new("IronStorage Test", "test@ironstorage.invalid").expect("valid identity") +} + +fn remote_config(temporary: &tempfile::TempDir) -> Result> { + fs::create_dir_all(temporary.path().join("config/keys"))?; + fs::create_dir_all(temporary.path().join("native"))?; + fs::create_dir_all(temporary.path().join("vault"))?; + let config_path = temporary.path().join("config/config.toml"); + fs::write( + &config_path, + format!( + "vault = {:?}\ndefault_key = \"0123456789ABCDEF0123456789ABCDEF01234567\"\nkey_material = \"keys\"\n[[git.remotes]]\nname = \"origin\"\nurl = \"https://example.test/store.git\"\nserver_id = \"server\"\napplication_id = \"application\"\n", + temporary.path().join("vault") + ), + )?; + Ok( + ConfigLoader::new(temporary.path().to_owned(), temporary.path().join("native")) + .load(Some(&config_path))?, + ) +} + +#[test] +fn local_git_workflow_stages_commits_diffs_logs_and_deletes() -> TestResult { + let temporary = tempfile::tempdir()?; + let store = Repository::open(temporary.path())?; + let git = GitRepository::init(&store, identity())?; + + fs::write(temporary.path().join(".gpg-id"), b"ALICE\n")?; + git.stage(&[".gpg-id".into()])?; + let first = git.commit("Set password store recipients.")?; + assert_eq!(first.len(), 40); + assert!(git.status()?.is_clean()); + + fs::write(temporary.path().join(".gpg-id"), b"BOB\n")?; + let status = git.status()?; + assert_eq!(status.unstaged().len(), 1); + assert_eq!(status.unstaged()[0].kind(), GitChangeKind::Modified); + let diff = git.diff(&[])?; + assert_eq!(diff[0].old(), Some(b"ALICE\n".as_slice())); + assert_eq!(diff[0].current(), Some(b"BOB\n".as_slice())); + + git.stage(&[".gpg-id".into()])?; + assert_eq!(git.status()?.staged()[0].kind(), GitChangeKind::Modified); + git.commit("Change password store recipients.")?; + assert_eq!(git.log(None)?.len(), 3); + assert_eq!( + git.log(Some(1))?[0].message(), + "Change password store recipients." + ); + assert_eq!(git.stage_and_commit(&[".gpg-id".into()], "No-op")?, None); + + fs::remove_file(temporary.path().join(".gpg-id"))?; + git.stage(&[".gpg-id".into()])?; + let deletion = git.status()?; + assert_eq!(deletion.staged().len(), 1, "{deletion:?}"); + assert_eq!(deletion.staged()[0].kind(), GitChangeKind::Deleted); + git.commit("Remove password store recipients.")?; + assert!(git.status()?.is_clean()); + Ok(()) +} + +#[test] +fn nested_repository_selection_is_innermost() -> TestResult { + let temporary = tempfile::tempdir()?; + let outer_store = Repository::open(temporary.path())?; + GitRepository::init(&outer_store, identity())?; + fs::create_dir(temporary.path().join("nested"))?; + let inner_store = Repository::open(temporary.path().join("nested"))?; + GitRepository::init(&inner_store, identity())?; + fs::write(temporary.path().join("nested/secret.gpg"), b"ciphertext")?; + + let selected = + GitRepository::open_innermost(&outer_store, Path::new("nested/secret.gpg"), identity())?; + assert_eq!(selected.root(), temporary.path().join("nested")); + Ok(()) +} + +#[test] +fn remotes_and_config_are_local_https_only() -> TestResult { + let temporary = tempfile::tempdir()?; + let store = Repository::open(temporary.path())?; + let mut git = GitRepository::init(&store, identity())?; + for forbidden in [ + "ssh://example.test/store.git", + "git@example.test:store.git", + "git://example.test/store.git", + "file:///tmp/store.git", + "../store.git", + "ext::helper command", + "https://user:secret@example.test/store.git", + ] { + assert_eq!( + git.add_remote("origin", forbidden), + Err(GitError::ForbiddenRemoteUrl) + ); + } + git.add_remote("origin", "https://example.test/store.git")?; + assert_eq!(git.remotes(), ["origin"]); + assert_eq!(git.remote_url("origin")?, "https://example.test/store.git"); + git.set_remote_url("origin", "https://example.test/other.git")?; + assert_eq!(git.remote_url("origin")?, "https://example.test/other.git"); + git.config_set("user.name", "Local User")?; + assert_eq!(git.config_get("user.name")?.as_deref(), Some("Local User")); + assert!(git.config_set("credential.helper", "evil").is_err()); + git.remove_remote("origin")?; + assert!(git.remotes().is_empty()); + Ok(()) +} + +struct Credentials; + +impl GitCredentialProvider for Credentials { + fn credential( + &self, + server: &ironstorage::config::ServerId, + application: &ironstorage::config::ApplicationId, + ) -> Result { + assert_eq!(server.as_str(), "server"); + assert_eq!(application.as_str(), "application"); + GitCredential::new("alice", b"token".to_vec()) + } +} + +struct SigningSecret(Vec); + +impl SecretProvider for SigningSecret { + fn secret_for(&mut self, _key: &KeyInfo) -> Result { + Ok(SecretBytes::new(self.0.clone())) + } +} + +#[derive(Default)] +struct RecordingTransport { + request: Mutex>, +} + +struct CloningFetch; + +impl GitFetchTransport for CloningFetch { + fn fetch( + &self, + repository: &GitRepository, + _configured: &GitRemote, + credential: &GitCredential, + ) -> Result { + assert_eq!(credential.password(), b"token"); + fs::write(repository.root().join(".gpg-id"), b"ALICE\n") + .map_err(|error| GitError::InvalidRepository(error.to_string()))?; + repository.stage(&[".gpg-id".into()])?; + let tip = repository.commit("Fetched initial store.")?; + set_remote_tracking(repository.root(), &tip) + .map_err(|error| GitError::InvalidRepository(error.to_string()))?; + reset_to_unborn(repository.root()) + .map_err(|error| GitError::InvalidRepository(error.to_string()))?; + Ok(true) + } +} + +struct NoopFetch; + +impl GitFetchTransport for NoopFetch { + fn fetch( + &self, + _repository: &GitRepository, + _configured: &GitRemote, + credential: &GitCredential, + ) -> Result { + assert_eq!(credential.username(), "alice"); + Ok(false) + } +} + +struct AuthenticationFailure; + +impl GitSmartHttpTransport for AuthenticationFailure { + fn advertise_receive_pack( + &self, + _url: &url::Url, + _credential: &GitCredential, + ) -> Result, GitError> { + Err(GitError::AuthenticationFailed) + } + + fn receive_pack( + &self, + _url: &url::Url, + _credential: &GitCredential, + _request: Vec, + ) -> Result, GitError> { + unreachable!("authentication fails before receive-pack") + } +} + +struct NonFastForwardAdvertisement; + +impl GitSmartHttpTransport for NonFastForwardAdvertisement { + fn advertise_receive_pack( + &self, + _url: &url::Url, + _credential: &GitCredential, + ) -> Result, GitError> { + let mut output = packet(b"# service=git-receive-pack\n"); + output.extend_from_slice(b"0000"); + output.extend_from_slice(&packet( + b"1111111111111111111111111111111111111111 refs/heads/main\0report-status\n", + )); + output.extend_from_slice(b"0000"); + Ok(output) + } + + fn receive_pack( + &self, + _url: &url::Url, + _credential: &GitCredential, + _request: Vec, + ) -> Result, GitError> { + unreachable!("non-fast-forward is rejected before receive-pack") + } +} + +impl GitSmartHttpTransport for RecordingTransport { + fn advertise_receive_pack( + &self, + _url: &url::Url, + credential: &GitCredential, + ) -> Result, GitError> { + assert_eq!(credential.username(), "alice"); + let mut output = packet(b"# service=git-receive-pack\n"); + output.extend_from_slice(b"0000"); + output.extend_from_slice(&packet(b"0000000000000000000000000000000000000000 capabilities^{}\0report-status delete-refs\n")); + output.extend_from_slice(b"0000"); + Ok(output) + } + + fn receive_pack( + &self, + _url: &url::Url, + _credential: &GitCredential, + request: Vec, + ) -> Result, GitError> { + *self.request.lock().expect("request lock") = request; + let mut output = packet(b"unpack ok\n"); + output.extend_from_slice(&packet(b"ok refs/heads/main\n")); + output.extend_from_slice(b"0000"); + Ok(output) + } +} + +#[test] +fn injected_smart_http_push_sends_a_complete_pack_and_credentials() -> TestResult { + let temporary = tempfile::tempdir()?; + let config = remote_config(&temporary)?; + let remote: &GitRemote = &config.git_remotes()[0]; + let store = Repository::open(config.vault())?; + let mut git = GitRepository::init(&store, identity())?; + git.add_remote("origin", remote.url().as_str())?; + fs::write(config.vault().join("secret.gpg"), b"ciphertext")?; + git.stage(&["secret.gpg".into()])?; + let head = git.commit("Add secret to store.")?; + + let transport = RecordingTransport::default(); + let outcome = git.push_with_transport(remote, Some("main"), &Credentials, &transport)?; + assert_eq!(outcome.new_id(), head); + let request = transport.request.lock().expect("request lock"); + let pack_offset = request + .windows(4) + .position(|window| window == b"PACK") + .expect("pack payload"); + assert!( + request[..pack_offset] + .windows(b"refs/heads/main".len()) + .any(|window| window == b"refs/heads/main") + ); + let pack = &request[pack_offset..]; + assert_eq!(&pack[..4], b"PACK"); + assert_eq!(u32::from_be_bytes(pack[4..8].try_into()?), 2); + assert!(u32::from_be_bytes(pack[8..12].try_into()?) >= 3); + let digest = sha1::Sha1::digest(&pack[..pack.len() - 20]); + assert_eq!(digest.as_slice(), &pack[pack.len() - 20..]); + Ok(()) +} + +#[test] +fn push_propagates_authentication_and_rejects_non_fast_forward_before_upload() -> TestResult { + let temporary = tempfile::tempdir()?; + let config = remote_config(&temporary)?; + let remote = &config.git_remotes()[0]; + let store = Repository::open(config.vault())?; + let mut git = GitRepository::init(&store, identity())?; + git.add_remote("origin", remote.url().as_str())?; + assert_eq!( + git.push_with_transport(remote, Some("main"), &Credentials, &AuthenticationFailure), + Err(GitError::AuthenticationFailed) + ); + assert_eq!( + git.push_with_transport( + remote, + Some("main"), + &Credentials, + &NonFastForwardAdvertisement, + ), + Err(GitError::NonFastForward) + ); + Ok(()) +} + +#[test] +fn fetched_branches_fast_forward_and_report_typed_conflicts() -> TestResult { + let temporary = tempfile::tempdir()?; + let config = remote_config(&temporary)?; + let remote = &config.git_remotes()[0]; + let store = Repository::open(config.vault())?; + let mut git = GitRepository::init(&store, identity())?; + git.add_remote("origin", remote.url().as_str())?; + + fs::write(config.vault().join("secret.gpg"), b"base")?; + git.stage(&["secret.gpg".into()])?; + let base = git.commit("Base")?; + fs::write(config.vault().join("secret.gpg"), b"remote")?; + git.stage(&["secret.gpg".into()])?; + let remote_tip = git.commit("Remote change")?; + + reset_head_and_index(&git, config.vault(), &base, b"base")?; + set_remote_tracking(config.vault(), &remote_tip)?; + assert_eq!( + git.pull_with_transport(remote, Some("main"), &Credentials, &NoopFetch)?, + PullOutcome::FastForward + ); + assert_eq!(fs::read(config.vault().join("secret.gpg"))?, b"remote"); + + reset_head_and_index(&git, config.vault(), &base, b"base")?; + fs::write(config.vault().join("secret.gpg"), b"local")?; + git.stage(&["secret.gpg".into()])?; + git.commit("Local change")?; + let error = git + .pull_with_transport(remote, Some("main"), &Credentials, &NoopFetch) + .expect_err("conflicting histories"); + assert_eq!( + error, + GitError::MergeConflicts { + paths: vec!["secret.gpg".into()] + } + ); + assert_eq!(fs::read(config.vault().join("secret.gpg"))?, b"local"); + Ok(()) +} + +#[test] +fn clone_uses_a_private_directory_and_injected_fetch_transport() -> TestResult { + let temporary = tempfile::tempdir()?; + let config = remote_config(&temporary)?; + let destination = temporary.path().join("cloned-vault"); + let cloned = GitRepository::clone_into_with_transport( + &destination, + identity(), + &config.git_remotes()[0], + &Credentials, + &CloningFetch, + )?; + assert_eq!(cloned.root(), destination); + assert_eq!(fs::read(destination.join(".gpg-id"))?, b"ALICE\n"); + assert_eq!(cloned.remotes(), ["origin"]); + assert_eq!(cloned.log(None)?.len(), 1); + assert_eq!(cloned.log(None)?[0].message(), "Fetched initial store."); + assert!(!destination.join(".gitattributes").exists()); + assert!( + !temporary + .path() + .read_dir()? + .filter_map(Result::ok) + .any(|entry| entry + .file_name() + .to_string_lossy() + .starts_with(".ironstorage-clone-")) + ); + Ok(()) +} + +#[test] +fn signed_commits_have_a_verifiable_ascii_armored_gpgsig() -> TestResult { + let fixture = FixtureSet::load()?; + let alice = fixture.key("alice")?; + let keys = KeyStore::load(fixture.path("keys"))?; + let signer = keys.resolve(&alice.primary_fingerprint)?; + let temporary = tempfile::tempdir()?; + let store = Repository::open(temporary.path())?; + let git = GitRepository::init(&store, identity())?; + fs::write(temporary.path().join("secret.gpg"), b"ciphertext")?; + git.stage(&["secret.gpg".into()])?; + git.commit_signed( + "Add signed password.", + &keys, + &signer, + &mut SigningSecret(alice.passphrase.as_bytes().to_vec()), + )?; + + let repository = open_test_git(temporary.path())?; + let head = repository.head_commit()?; + let decoded = head.decode()?; + let mut commit = decoded.into_owned()?; + let signature_index = commit + .extra_headers + .iter() + .position(|(name, _)| name.as_slice() == b"gpgsig") + .expect("gpgsig header"); + let armor = commit.extra_headers.remove(signature_index).1; + assert!(armor.starts_with(b"-----BEGIN PGP SIGNATURE-----")); + let (signature, _) = DetachedSignature::from_armor_single(Cursor::new(armor))?; + let mut signature_bytes = Vec::new(); + signature.to_writer(&mut signature_bytes)?; + let mut unsigned = Vec::new(); + gix::objs::WriteTo::write_to(&commit, &mut unsigned)?; + assert_eq!( + keys.verify( + &unsigned, + &DetachedSignatureBytes::new(signature_bytes), + std::slice::from_ref(&signer), + )?, + signer + ); + Ok(()) +} + +#[test] +fn successful_storage_mutations_use_the_embedded_committer() -> TestResult { + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + let mut git = GitRepository::init(&repository, identity())?; + assert_eq!( + fs::read(store.path().join(".gitattributes"))?, + b"*.gpg diff=gpg\n" + ); + let initialized = git.log(None)?; + assert_eq!(initialized.len(), 2); + assert_eq!( + initialized[0].message(), + "Configure git repository for gpg file diff." + ); + assert_eq!( + initialized[1].message(), + "Add current contents of password store." + ); + fs::write(store.path().join("unrelated.gpg"), b"separately staged")?; + git.stage(&["unrelated.gpg".into()])?; + let writer = VaultWriter::new(&repository, &keys); + writer.insert( + &InsertRequest { + entry: "automatic/entry".to_owned(), + input: InsertInput::EchoedLine, + force: false, + }, + InsertContent::echoed(b"generated secret".to_vec())?, + OverwriteDecision::Decline, + None, + &mut git, + )?; + let log = git.log(None)?; + assert_eq!(log.len(), 3); + assert_eq!( + log[0].message(), + "Add given password for automatic/entry to store." + ); + let embedded = open_test_git(store.path())?; + let head = embedded.head_commit()?; + let committed = head.tree()?; + assert!( + committed + .lookup_entry_by_path("automatic/entry.gpg")? + .is_some() + ); + assert!(committed.lookup_entry_by_path("unrelated.gpg")?.is_none()); + let status = git.status()?; + assert_eq!(status.staged().len(), 1, "{status:?}"); + assert_eq!(status.staged()[0].path(), Path::new("unrelated.gpg")); + assert_eq!(status.staged()[0].kind(), GitChangeKind::Added); + Ok(()) +} + +fn reset_head_and_index(git: &GitRepository, root: &Path, id: &str, contents: &[u8]) -> TestResult { + let repository = open_test_git(root)?; + let id = gix::hash::ObjectId::from_hex(id.as_bytes())?; + repository + .head_ref()? + .expect("born HEAD") + .set_target_id(id, "test reset")?; + fs::write(root.join("secret.gpg"), contents)?; + git.stage(&["secret.gpg".into()])?; + Ok(()) +} + +fn set_remote_tracking(root: &Path, id: &str) -> TestResult { + let repository = open_test_git(root)?; + let id = gix::hash::ObjectId::from_hex(id.as_bytes())?; + if let Some(existing) = repository.try_find_reference("refs/remotes/origin/main")? { + existing.delete()?; + } + repository.reference( + "refs/remotes/origin/main", + id, + gix::refs::transaction::PreviousValue::MustNotExist, + "test remote update", + )?; + Ok(()) +} + +fn reset_to_unborn(root: &Path) -> TestResult { + let repository = open_test_git(root)?; + repository.find_reference("refs/heads/main")?.delete()?; + let mut index = repository.index_from_tree(&repository.empty_tree().id)?; + index.write(Default::default())?; + fs::remove_file(root.join(".gpg-id"))?; + Ok(()) +} + +fn open_test_git(root: &Path) -> Result> { + Ok(gix::open_opts( + root, + gix::open::Options::isolated().config_overrides([ + "user.name=Test".to_owned(), + "user.email=test@ironstorage.invalid".to_owned(), + ]), + )?) +} + +fn packet(data: &[u8]) -> Vec { + let mut output = format!("{:04x}", data.len() + 4).into_bytes(); + output.extend_from_slice(data); + output +} diff --git a/crates/storage/tests/tree_mutations.rs b/crates/storage/tests/tree_mutations.rs index 600841f..99a4ac3 100644 --- a/crates/storage/tests/tree_mutations.rs +++ b/crates/storage/tests/tree_mutations.rs @@ -151,6 +151,10 @@ fn copy_preserves_source_and_reencrypts_for_destination_policy() -> TestResult { committer.changes[0].message(), "Copy email/personal to team/personal." ); + assert_eq!( + committer.changes[0].changed_paths(), + &[Path::new("team/personal.gpg").to_owned()] + ); Ok(()) } @@ -365,6 +369,13 @@ fn move_writes_durable_destination_before_removing_source() -> TestResult { committer.changes[0].message(), "Rename email/personal to archive/personal." ); + assert_eq!( + committer.changes[0].changed_paths(), + &[ + Path::new("archive/personal.gpg").to_owned(), + Path::new("email/personal.gpg").to_owned(), + ] + ); Ok(()) } diff --git a/docs/git-synchronization.md b/docs/git-synchronization.md new file mode 100644 index 0000000..12af823 --- /dev/null +++ b/docs/git-synchronization.md @@ -0,0 +1,47 @@ +# Embedded Git and synchronization + +All Git behavior is implemented in `crates/storage`. IronStorage never launches +`git`, a credential helper, an SSH client, a hook, a filter, or a merge driver. +Repositories are opened with isolated configuration and environment access; +repository-local configuration that could name an executable is rejected. + +`GitRepository` initializes and opens password-store worktrees, selects the +innermost repository for a nested entry, and implements status, log, diff, add, +commit, remote, and local-config operations. Successful insert, edit, generate, +recipient-policy, remove, move, and copy transactions use the same concrete +committer. Only their affected paths are staged, unrelated index state is +preserved, no-op mutations create no commit, and commit failures restore the +index so the storage transaction can roll back its files. + +## HTTPS transport + +Remote URLs must be absolute, credential-free HTTPS URLs. SSH, scp syntax, +`git://`, `file://`, local paths, helper transports, URL rewrites, separate push +URLs, and unknown schemes are rejected before transport. Credentials are +requested with the configured server ID and application ID and remain outside +Git configuration. + +Fetch uses the embedded Rust smart-HTTP client with an explicit credential +callback, so Git's credential cascade is never entered. Push implements the +receive-pack protocol directly: it validates the advertisement, checks the +remote tip is an ancestor, creates a complete Git pack with a SHA-1 trailer, +requests `report-status`, and accepts the update only after both unpack and ref +status succeed. HTTP redirects are disabled so authorization cannot cross an +origin boundary. + +Pull refuses a dirty worktree. It fast-forwards when possible and otherwise +uses the embedded three-way tree merge. Unresolved paths are returned as typed +`MergeConflicts`; no conflict markers or partial checkout are written. Checkout +prevalidates tree entries, rejects links and submodules, writes private files +atomically, updates the real Git index, and rolls the worktree/index back if the +reference update fails. `sync` performs pull before push, while clone builds in +a private sibling directory and installs the completed vault with a rename. + +Commit signing is optional. The embedded OpenPGP key store signs the canonical +unsigned commit bytes and adds an ASCII-armored `gpgsig` header compatible with +Git/GPG without invoking `gpg`. + +The smart-HTTP boundary is injectable for deterministic compatibility tests. +Tests can inspect credentials, advertisements, receive-pack commands, object +counts, pack checksums, non-fast-forward behavior, and server status without a +runtime helper or external Git installation. diff --git a/docs/recipient-policies.md b/docs/recipient-policies.md index 329ea26..1b8fbbc 100644 --- a/docs/recipient-policies.md +++ b/docs/recipient-policies.md @@ -53,5 +53,5 @@ no-op, and must restore any staging state before returning an error. The policy manager then restores repository bytes. `NoGitCommitter` represents a path not contained in a Git work tree; it is not used for a discovered repository. -The later embedded-Git implementation owns concrete staging and commits, while -this module owns the all-or-nothing storage mutation contract it invokes. +The embedded Git implementation owns concrete staging and commits, while this +module owns the all-or-nothing storage mutation contract it invokes.