From 9ec3461edf992e1a51ff45661fa830bba6103120 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Tue, 8 Sep 2026 22:07:10 +0100 Subject: [PATCH 1/3] feat: integrate IPC hardening and live TUI for the XPS trial Integrates #224 and #226 with bounded subscriptions, sealed recent-message snapshots, live delivery and unread state, reconnect, terminal lifecycle checks, and packaging preparation. Fixes #223. Fixes #225. --- .github/workflows/ci.yml | 3 + .github/workflows/fuzz.yml | 2 +- Cargo.lock | 518 +++++++++++++++++- README.md | 2 +- SECURITY.md | 18 +- crates/omachat-ctl/Cargo.toml | 2 +- crates/omachat-ctl/src/lib.rs | 215 +++++++- crates/omachat-ctl/src/main.rs | 7 +- crates/omachat-ctl/tests/confirmation_flow.rs | 120 ++++ crates/omachat-ctl/tests/events.rs | 101 ++++ crates/omachat-proto/src/ipc.rs | 37 +- crates/omachat-proto/tests/ipc.rs | 130 +++-- crates/omachat-proto/tests/nip65_ipc_wire.rs | 6 +- crates/omachat-tui/Cargo.toml | 3 +- crates/omachat-tui/src/lib.rs | 477 +++++++++++++--- crates/omachat-tui/src/main.rs | 365 ++++++++++-- crates/omachat-tui/tests/ui.rs | 180 +++++- crates/omachatd/Cargo.toml | 1 + crates/omachatd/src/chat_history.rs | 144 +++++ crates/omachatd/src/confirmation.rs | 247 +++++++++ crates/omachatd/src/core.rs | 174 +++++- crates/omachatd/src/core_error.rs | 17 +- crates/omachatd/src/ipc_server.rs | 120 +++- crates/omachatd/src/lib.rs | 6 + crates/omachatd/src/main.rs | 7 +- crates/omachatd/tests/confirmation_ipc.rs | 146 +++++ crates/omachatd/tests/core.rs | 42 +- crates/omachatd/tests/dm_inbox_enable.rs | 28 +- crates/omachatd/tests/geo_relay_runtime.rs | 13 +- crates/omachatd/tests/ipc_server.rs | 30 +- crates/omachatd/tests/ipc_socket_mode.rs | 112 ++++ .../tests/principal_registry_claim.rs | 29 +- .../omachatd/tests/profile_publication_ipc.rs | 27 +- crates/omachatd/tests/registry_claim.rs | 35 +- deny.toml | 8 + docs/development.md | 10 +- docs/implementation-status.md | 20 +- docs/installation.md | 40 +- packaging/man/omachat-protocol.7 | 8 +- packaging/man/omachat.1 | 7 + packaging/systemd/omachatd.service | 4 +- scripts/test-local-dm.py | 65 +++ scripts/test-tui-pty.py | 142 +++++ 43 files changed, 3402 insertions(+), 266 deletions(-) create mode 100644 crates/omachat-ctl/tests/confirmation_flow.rs create mode 100644 crates/omachat-ctl/tests/events.rs create mode 100644 crates/omachatd/src/chat_history.rs create mode 100644 crates/omachatd/src/confirmation.rs create mode 100644 crates/omachatd/tests/confirmation_ipc.rs create mode 100644 crates/omachatd/tests/ipc_socket_mode.rs create mode 100644 scripts/test-local-dm.py create mode 100644 scripts/test-tui-pty.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef7bf8b..704383e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,9 @@ jobs: - name: Build size-optimized release binaries run: cargo build --workspace --bins --release --locked + - name: Exercise live TUI and terminal restoration + run: python3 scripts/test-tui-pty.py + - name: Check release binary size budget run: ./scripts/check-release-size.sh diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 71ad81b..81d934e 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -17,4 +17,4 @@ jobs: with: toolchain: nightly - run: cargo install cargo-fuzz --locked - - run: cargo fuzz run hostile-codecs -- -max_total_time=300 -rss_limit_mb=1024 + - run: cargo +nightly fuzz run hostile-codecs -- -max_total_time=300 -rss_limit_mb=1024 diff --git a/Cargo.lock b/Cargo.lock index aa0a1e9..283d67b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,21 @@ dependencies = [ "cpufeatures 0.3.1", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "asn1-rs" version = "0.7.2" @@ -197,7 +212,7 @@ dependencies = [ "pin-project", "serde", "serde_json", - "strum", + "strum 0.26.3", "tokio", "tokio-stream", "uuid", @@ -209,12 +224,27 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cbc" version = "0.2.1" @@ -308,6 +338,20 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -320,6 +364,15 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -369,6 +422,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -452,7 +532,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a707ceda8652f6c7624f2be725652e9524c815bf3b9d55a0b2320be2303f9c11" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn 2.0.119", @@ -465,8 +545,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core 0.24.1", + "darling_macro 0.24.1", ] [[package]] @@ -483,17 +573,41 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.4", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", "quote", "syn 2.0.119", ] +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core 0.24.1", + "quote", + "syn 3.0.4", +] + [[package]] name = "data-encoding" version = "2.11.1" @@ -563,6 +677,28 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.10.7" @@ -598,6 +734,15 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "ecdsa" version = "0.16.9" @@ -768,6 +913,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -923,11 +1074,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "heck" @@ -1119,7 +1286,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", ] [[package]] @@ -1141,6 +1317,28 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "instability" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +dependencies = [ + "darling 0.24.1", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1172,6 +1370,17 @@ dependencies = [ "signature", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.20", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1194,6 +1403,21 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1206,12 +1430,36 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "macaddr" version = "1.0.1" @@ -1256,6 +1504,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1371,6 +1620,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "oid-registry" version = "0.8.1" @@ -1538,6 +1796,7 @@ version = "0.0.1" dependencies = [ "omachat-ctl", "omachat-proto", + "ratatui", "serde_json", "tokio", ] @@ -1559,6 +1818,7 @@ dependencies = [ "omachat-registry-host", "omachat-registry-transport", "omachat-store", + "rustix", "serde", "serde_json", "tempfile", @@ -1595,12 +1855,68 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + [[package]] name = "parking" version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "pem" version = "4.0.0" @@ -1670,6 +1986,12 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + [[package]] name = "potential_utf" version = "0.1.6" @@ -1744,6 +2066,72 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags", + "compact_str", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum 0.28.0", + "thiserror 2.0.20", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum 0.28.0", + "time", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "rcgen" version = "0.14.10" @@ -1758,6 +2146,15 @@ dependencies = [ "yasna", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "ring" version = "0.17.14" @@ -1855,6 +2252,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "schannel" version = "0.1.29" @@ -1864,6 +2267,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "sec1" version = "0.7.3" @@ -2019,6 +2428,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -2083,6 +2513,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -2095,7 +2531,16 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -2111,6 +2556,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2210,7 +2667,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -2422,6 +2881,29 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "universal-hash" version = "0.5.1" @@ -2525,6 +3007,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/README.md b/README.md index 816f057..4aafd18 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ advertising must remain disabled until its live health gate succeeds. ## Development -The repository is a nine-crate Rust workspace pinned to Rust 1.98.0. It now +The repository is a eleven-crate Rust workspace pinned to Rust 1.98.0. It now contains bounded protocol codecs, distinct device/account/recovery keys, sealed persistence, daemon IPC, CLI/TUI surfaces, and pre-release packaging assets. Size-optimized release builds of the three installed binaries diff --git a/SECURITY.md b/SECURITY.md index 175fbb1..f9f677b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -35,9 +35,21 @@ under the account root. A configured handle is only a local candidate. No global uniqueness, registry freshness, recovery, revocation, or key-transparency claim exists until their complete verification paths ship. -IPC is versioned, length bounded, and carried over a 0600 Unix socket. The -systemd user service restricts writable paths and privileges, but Bluetooth, -network, Unix-socket, and user-session D-Bus access remain necessary. +IPC is versioned, length bounded, and carried over a Unix socket created 0600 +inside a private staging directory before atomic publication. The daemon checks SO_PEERCRED on every accepted +connection and drops any peer whose uid differs from its own effective uid, +root included. Destructive commands (panic erase, registry handle claims) +additionally require a single-use confirmation token with a 120-second +lifetime, minted on request into a 0600 file inside the sealed state +directory and echoed back in a second step. These are deliberate-two-step +and freshness guarantees, not an authorization boundary: same-uid isolation +is not a boundary this daemon can enforce alone. A process running as the +same user can ultimately ptrace the daemon, read its memory, or read the +state directory that holds the confirmation tokens. Protecting the account +from hostile same-user code — including coding agents — requires OS-level +sandboxing of that code, not daemon-side checks. The systemd user service +restricts writable paths and privileges, but Bluetooth, network, +Unix-socket, and user-session D-Bus access remain necessary. ## Metadata and network limits diff --git a/crates/omachat-ctl/Cargo.toml b/crates/omachat-ctl/Cargo.toml index c171043..12233e2 100644 --- a/crates/omachat-ctl/Cargo.toml +++ b/crates/omachat-ctl/Cargo.toml @@ -10,7 +10,7 @@ repository.workspace = true omachat-proto = { path = "../omachat-proto", version = "=0.0.1" } serde = { version = "=1.0.229", features = ["derive"] } serde_json = "=1.0.151" -tokio = { version = "=1.53.1", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] } +tokio = { version = "=1.53.1", features = ["io-util", "macros", "net", "rt-multi-thread", "time", "sync"] } [dev-dependencies] tempfile = "=3.27.0" diff --git a/crates/omachat-ctl/src/lib.rs b/crates/omachat-ctl/src/lib.rs index e832fe9..7c5bbf9 100644 --- a/crates/omachat-ctl/src/lib.rs +++ b/crates/omachat-ctl/src/lib.rs @@ -1,20 +1,25 @@ //! Bounded IPC client used by the scripting command. use omachat_proto::ipc::{ - Command, MAX_LINE_BYTES, Request, Response, ResponseOutcome, VERSION, encode_line, + Command, Event, MAX_LINE_BYTES, Request, Response, ResponseOutcome, Topic, VERSION, encode_line, }; use serde::de::DeserializeOwned; use std::{error::Error, fmt, path::Path, time::Duration}; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, - net::UnixStream, + net::{UnixStream, unix::OwnedWriteHalf}, + sync::mpsc, + task::JoinHandle, time::timeout, }; pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5); pub struct Client { - stream: UnixStream, + stream: OwnedWriteHalf, + responses: mpsc::Receiver>, + events: Option>, + reader: JoinHandle<()>, next_id: u64, timeout: Duration, } @@ -28,8 +33,48 @@ impl Client { .await .map_err(|_| ClientError::Timeout)? .map_err(ClientError::Io)?; + let (mut read, stream) = stream.into_split(); + let (response_tx, responses) = mpsc::channel(1); + let (event_tx, events) = mpsc::channel(64); + let reader = tokio::spawn(async move { + loop { + let parsed = read_line::(&mut read).await; + let result = match parsed { + Ok(value) if value.get("topic").is_some() => { + match serde_json::from_value::(value) { + Ok(event) if event.version == VERSION => { + if event_tx.try_send(event).is_err() { + Err(ClientError::EventOverflow) + } else { + continue; + } + } + Ok(event) => Err(ClientError::VersionMismatch(event.version)), + Err(_) => Err(ClientError::MalformedResponse), + } + } + Ok(value) => serde_json::from_value::(value) + .map_err(|_| ClientError::MalformedResponse) + .and_then(|response| { + if response.version != VERSION { + Err(ClientError::VersionMismatch(response.version)) + } else { + Ok(response) + } + }), + Err(error) => Err(error), + }; + let failed = result.is_err(); + if response_tx.try_send(result).is_err() || failed { + break; + } + } + }); let mut client = Self { stream, + responses, + events: Some(events), + reader, next_id: 1, timeout: timeout_duration, }; @@ -49,6 +94,14 @@ impl Client { } pub async fn request(&mut self, command: Command) -> Result { + let result = self.request_inner(command).await; + if result.is_err() { + self.reader.abort(); + } + result + } + + async fn request_inner(&mut self, command: Command) -> Result { let id = self.next_id.to_string(); self.next_id = self.next_id.saturating_add(1); let request = Request { @@ -61,9 +114,14 @@ impl Client { .await .map_err(|_| ClientError::Timeout)? .map_err(ClientError::Io)?; - let response: Response = timeout(self.timeout, read_line(&mut self.stream)) - .await - .map_err(|_| ClientError::Timeout)??; + let response = match timeout(self.timeout, self.responses.recv()).await { + Ok(Some(response)) => response?, + Ok(None) => return Err(ClientError::Disconnected), + Err(_) => { + self.reader.abort(); + return Err(ClientError::Timeout); + } + }; if response.version != VERSION { return Err(ClientError::VersionMismatch(response.version)); } @@ -72,9 +130,131 @@ impl Client { } Ok(response) } + + /// Subscribe once. A full event queue terminates the reader; reconnect and + /// resubscribe to recover a fresh daemon snapshot instead of dropping events. + pub async fn subscribe( + &mut self, + topics: Vec, + ) -> Result<(serde_json::Value, mpsc::Receiver), ClientError> { + if self.events.is_none() { + return Err(ClientError::AlreadySubscribed); + } + let response = self.request(Command::Subscribe { topics }).await?; + match response.outcome { + ResponseOutcome::Ok { result } => { + Ok((result, self.events.take().expect("checked receiver"))) + } + ResponseOutcome::Error { error } => Err(ClientError::Remote { + code: format!("{:?}", error.code), + message: error.message, + }), + } + } } -async fn read_line(stream: &mut UnixStream) -> Result { +impl Drop for Client { + fn drop(&mut self) { + self.reader.abort(); + } +} + +/// Restated at every panic invocation: erasure is local-only. +pub const PANIC_ERASE_WARNING: &str = "panic erase destroys the local master key and sealed \ +state; it cannot retract messages, keys, or metadata already replicated to relays, peers, or \ +backups"; + +/// Two-phase orchestration for destructive commands. The typed intent +/// (`ERASE`, or the handle echoed to `--confirm`) is checked locally; the +/// daemon-minted single-use token is then fetched out of band from the +/// daemon state directory and echoed back. Non-destructive commands pass +/// straight through. +pub async fn request_with_confirmation( + client: &mut Client, + command: Command, +) -> Result { + match command { + Command::Panic { confirmation } => { + if confirmation != "ERASE" { + return Err(ClientError::ConfirmationRefused( + "panic requires --confirm ERASE".into(), + )); + } + let issued = client.request(Command::RequestPanicConfirmation).await?; + let ResponseOutcome::Ok { ref result } = issued.outcome else { + return Ok(issued); + }; + let token = read_confirmation_token(result)?; + client + .request(Command::Panic { + confirmation: token, + }) + .await + } + Command::ClaimRegistryHandle { + handle, + confirmation, + } => { + if confirmation != handle { + return Err(ClientError::ConfirmationRefused( + "claim-handle requires --confirm HANDLE to echo the handle exactly".into(), + )); + } + let issued = client + .request(Command::RequestRegistryClaimConfirmation { + handle: handle.clone(), + }) + .await?; + let ResponseOutcome::Ok { ref result } = issued.outcome else { + return Ok(issued); + }; + let token = read_confirmation_token(result)?; + client + .request(Command::ClaimRegistryHandle { + handle, + confirmation: token, + }) + .await + } + other => client.request(other).await, + } +} + +fn read_confirmation_token(result: &serde_json::Value) -> Result { + let path = result + .get("token_path") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + ClientError::ConfirmationProtocol("daemon response lacked token_path".into()) + })?; + // Token files are tens of bytes; a blocking read keeps the client free + // of a tokio fs feature dependency. + let mut file = std::fs::File::open(path) + .map_err(|error| ClientError::ConfirmationProtocol(error.to_string()))?; + if !file + .metadata() + .map_err(|error| ClientError::ConfirmationProtocol(error.to_string()))? + .is_file() + { + return Err(ClientError::ConfirmationProtocol( + "token is not a regular file".into(), + )); + } + let mut token = String::new(); + std::io::Read::read_to_string(&mut std::io::Read::take(&mut file, 65), &mut token).map_err( + |error| ClientError::ConfirmationProtocol(format!("token file unreadable: {error}")), + )?; + if token.len() != 64 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(ClientError::ConfirmationProtocol( + "token must be 32 hex-encoded bytes".into(), + )); + } + Ok(token) +} + +async fn read_line( + stream: &mut (impl tokio::io::AsyncRead + Unpin), +) -> Result { let mut line = Vec::new(); let mut byte = [0_u8; 1]; loop { @@ -98,12 +278,23 @@ pub enum ClientError { Io(std::io::Error), Protocol(omachat_proto::ipc::IpcError), Timeout, + EventOverflow, + AlreadySubscribed, Disconnected, LineTooLarge, MalformedResponse, VersionMismatch(u16), CorrelationMismatch, - Remote { code: String, message: String }, + Remote { + code: String, + message: String, + }, + /// The locally typed intent (`--confirm` value) did not match; nothing + /// was sent to the daemon. + ConfirmationRefused(String), + /// The daemon's confirmation-token response was malformed or the token + /// file could not be read. + ConfirmationProtocol(String), } impl fmt::Display for ClientError { @@ -111,6 +302,10 @@ impl fmt::Display for ClientError { match self { Self::Io(error) => write!(formatter, "daemon connection failed: {error}"), Self::Protocol(error) => write!(formatter, "IPC request failed: {error}"), + Self::EventOverflow => { + formatter.write_str("daemon events exceeded client capacity; reconnect") + } + Self::AlreadySubscribed => formatter.write_str("client is already subscribed"), Self::Timeout => formatter.write_str("daemon request timed out"), Self::Disconnected => formatter.write_str("daemon disconnected"), Self::LineTooLarge => formatter.write_str("daemon response exceeds the size limit"), @@ -120,6 +315,10 @@ impl fmt::Display for ClientError { } Self::CorrelationMismatch => formatter.write_str("daemon response ID does not match"), Self::Remote { code, message } => write!(formatter, "daemon error {code}: {message}"), + Self::ConfirmationRefused(message) => write!(formatter, "refused: {message}"), + Self::ConfirmationProtocol(message) => { + write!(formatter, "confirmation protocol failed: {message}") + } } } } diff --git a/crates/omachat-ctl/src/main.rs b/crates/omachat-ctl/src/main.rs index 5ddb258..31d4917 100644 --- a/crates/omachat-ctl/src/main.rs +++ b/crates/omachat-ctl/src/main.rs @@ -43,7 +43,12 @@ async fn run(mut arguments: Vec) -> Result<(), CliError> { let mut client = Client::connect(socket, DEFAULT_TIMEOUT) .await .map_err(CliError::Client)?; - let response = client.request(command).await.map_err(CliError::Client)?; + if matches!(command, Command::Panic { .. }) { + eprintln!("{}", omachat_ctl::PANIC_ERASE_WARNING); + } + let response = omachat_ctl::request_with_confirmation(&mut client, command) + .await + .map_err(CliError::Client)?; match response.outcome { ResponseOutcome::Ok { result } => { if output_mode == OutputMode::Json { diff --git a/crates/omachat-ctl/tests/confirmation_flow.rs b/crates/omachat-ctl/tests/confirmation_flow.rs new file mode 100644 index 0000000..4241c03 --- /dev/null +++ b/crates/omachat-ctl/tests/confirmation_flow.rs @@ -0,0 +1,120 @@ +//! Two-phase destructive-command orchestration against a scripted stub daemon. + +use omachat_ctl::{Client, ClientError, DEFAULT_TIMEOUT, request_with_confirmation}; +use omachat_proto::ipc::{ + Command, RequestDecoder, Response, ResponseOutcome, VERSION, encode_line, negotiate, +}; +use serde_json::json; +use std::path::Path; +use tempfile::tempdir; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::UnixListener, +}; + +/// Serves one client: hello, then request-panic-confirmation (mints a token +/// file), then panic (accepted only with the minted token). +async fn stub_daemon(listener: UnixListener, token_directory: std::path::PathBuf) { + let (mut stream, _) = listener.accept().await.expect("accept"); + let mut decoder = RequestDecoder::default(); + let mut buffer = [0_u8; 4096]; + let mut minted: Option = None; + loop { + let count = match stream.read(&mut buffer).await { + Ok(0) | Err(_) => return, + Ok(count) => count, + }; + for request in decoder.push(&buffer[..count]).expect("decode request") { + let outcome = match &request.command { + Command::Hello { + minimum_version, + maximum_version, + } => ResponseOutcome::Ok { + result: serde_json::to_value( + negotiate(*minimum_version, *maximum_version).expect("negotiate"), + ) + .expect("hello result"), + }, + Command::RequestPanicConfirmation => { + let token = "a".repeat(64); + let token_path = token_directory.join("panic.token"); + std::fs::write(&token_path, &token).expect("write token file"); + minted = Some(token); + ResponseOutcome::Ok { + result: json!({ + "token_path": token_path.display().to_string(), + "expires_at": 1_u64, + "ttl_seconds": 120_u64, + }), + } + } + Command::Panic { confirmation } => { + assert_eq!( + Some(confirmation.as_str()), + minted.as_deref(), + "client must echo the minted token, not the typed intent" + ); + ResponseOutcome::Ok { + result: json!({"panic": "erased"}), + } + } + command => panic!("unexpected command: {command:?}"), + }; + let response = Response { + version: VERSION, + id: request.id, + outcome, + }; + stream + .write_all(&encode_line(&response).expect("encode response")) + .await + .expect("write response"); + } + } +} + +async fn connect(socket: &Path) -> Client { + Client::connect(socket, DEFAULT_TIMEOUT) + .await + .expect("connect client") +} + +#[tokio::test] +async fn panic_orchestrates_token_request_and_commit() { + let temporary = tempdir().expect("temporary directory"); + let socket = temporary.path().join("stub.sock"); + let listener = UnixListener::bind(&socket).expect("bind stub"); + let server = tokio::spawn(stub_daemon(listener, temporary.path().to_owned())); + let mut client = connect(&socket).await; + let response = request_with_confirmation( + &mut client, + Command::Panic { + confirmation: "ERASE".into(), + }, + ) + .await + .expect("two-phase panic"); + assert!(matches!(response.outcome, ResponseOutcome::Ok { .. })); + drop(client); + server.await.expect("stub daemon"); +} + +#[tokio::test] +async fn mistyped_intent_is_refused_before_any_daemon_interaction() { + let temporary = tempdir().expect("temporary directory"); + let socket = temporary.path().join("stub.sock"); + let listener = UnixListener::bind(&socket).expect("bind stub"); + let server = tokio::spawn(stub_daemon(listener, temporary.path().to_owned())); + let mut client = connect(&socket).await; + let error = request_with_confirmation( + &mut client, + Command::Panic { + confirmation: "erase".into(), + }, + ) + .await + .expect_err("typed intent must be exact"); + assert!(matches!(error, ClientError::ConfirmationRefused(_))); + drop(client); + server.await.expect("stub daemon"); +} diff --git a/crates/omachat-ctl/tests/events.rs b/crates/omachat-ctl/tests/events.rs new file mode 100644 index 0000000..9f7ef6e --- /dev/null +++ b/crates/omachat-ctl/tests/events.rs @@ -0,0 +1,101 @@ +use omachat_ctl::{Client, ClientError, DEFAULT_TIMEOUT}; +use omachat_proto::ipc::{ + Command, Event, RequestDecoder, Response, ResponseOutcome, Topic, VERSION, encode_line, +}; +use serde_json::json; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::UnixListener, +}; + +async fn peer(listener: UnixListener, bad_version: bool, flood: bool) { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut decoder = RequestDecoder::default(); + let mut bytes = [0; 4096]; + loop { + let count = match socket.read(&mut bytes).await { + Ok(count) => count, + Err(error) if error.kind() == std::io::ErrorKind::ConnectionReset => return, + Err(error) => panic!("read: {error}"), + }; + if count == 0 { + return; + } + for request in decoder.push(&bytes[..count]).unwrap() { + if matches!(request.command, Command::Subscribe { .. } | Command::Status) { + for sequence in 0..if flood { 65 } else { 1 } { + let event = Event { + version: if bad_version { VERSION + 1 } else { VERSION }, + sequence, + topic: Topic::Messages, + payload: json!({"text": "interleaved"}), + }; + if socket + .write_all(&encode_line(&event).unwrap()) + .await + .is_err() + { + return; + } + } + } + let response = Response { + version: VERSION, + id: request.id, + outcome: ResponseOutcome::Ok { result: json!({}) }, + }; + if socket + .write_all(&encode_line(&response).unwrap()) + .await + .is_err() + { + return; + } + if matches!(request.command, Command::Status) { + return; + } + } + } +} + +#[tokio::test] +async fn events_before_responses_do_not_break_correlation() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ipc"); + let task = tokio::spawn(peer(UnixListener::bind(&path).unwrap(), false, false)); + let mut client = Client::connect(path, DEFAULT_TIMEOUT).await.unwrap(); + let (_, mut events) = client.subscribe(vec![Topic::Messages]).await.unwrap(); + assert_eq!(events.recv().await.unwrap().payload["text"], "interleaved"); + assert!(client.request(Command::Status).await.is_ok()); + assert_eq!(events.recv().await.unwrap().topic, Topic::Messages); + task.await.unwrap(); + assert!(events.recv().await.is_none()); +} + +#[tokio::test] +async fn incompatible_event_fails_closed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ipc"); + let task = tokio::spawn(peer(UnixListener::bind(&path).unwrap(), true, false)); + let mut client = Client::connect(path, DEFAULT_TIMEOUT).await.unwrap(); + assert!(matches!( + client.subscribe(vec![Topic::Messages]).await, + Err(ClientError::VersionMismatch(_)) + )); + drop(client); + task.await.unwrap(); +} + +#[tokio::test] +async fn stalled_event_consumer_disconnects_at_the_bound() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ipc"); + let task = tokio::spawn(peer(UnixListener::bind(&path).unwrap(), false, true)); + let mut client = Client::connect(path, DEFAULT_TIMEOUT).await.unwrap(); + assert!(matches!( + client.subscribe(vec![Topic::Messages]).await, + Err(ClientError::EventOverflow) + )); + drop(client); + task.await.unwrap(); +} diff --git a/crates/omachat-proto/src/ipc.rs b/crates/omachat-proto/src/ipc.rs index 39e18b7..ee22341 100644 --- a/crates/omachat-proto/src/ipc.rs +++ b/crates/omachat-proto/src/ipc.rs @@ -4,7 +4,12 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::{error::Error, fmt}; -pub const VERSION: u16 = 1; +/// Version 2 changed `Panic` and `ClaimRegistryHandle` incompatibly: their +/// `confirmation` field is now a daemon-minted single-use token obtained +/// through `RequestPanicConfirmation` / `RequestRegistryClaimConfirmation`, +/// not an in-band constant. A version-1 client's destructive flow no longer +/// works, so negotiation must reject it rather than fail at use time. +pub const VERSION: u16 = 2; pub const MAX_LINE_BYTES: usize = 64 * 1024; pub const MAX_CORRELATION_ID_BYTES: usize = 128; @@ -61,6 +66,15 @@ pub enum Command { handle: String, confirmation: String, }, + /// Mint a single-use, TTL-bounded confirmation token for `Panic`. The + /// token itself travels out of band via a 0600 file in the daemon state + /// directory; the response carries only the file path and expiry. + RequestPanicConfirmation, + /// Mint a single-use, TTL-bounded confirmation token for + /// `ClaimRegistryHandle` on exactly this handle. + RequestRegistryClaimConfirmation { + handle: String, + }, Who { geohash: String, }, @@ -207,6 +221,15 @@ enum StrictRequestWire { id: String, params: RegistryClaimParams, }, + RequestPanicConfirmation { + version: u16, + id: String, + }, + RequestRegistryClaimConfirmation { + version: u16, + id: String, + params: HandleParams, + }, Who { version: u16, id: String, @@ -408,6 +431,18 @@ impl From for Request { confirmation, }, ), + StrictRequestWire::RequestPanicConfirmation { version, id } => { + (version, id, Command::RequestPanicConfirmation) + } + StrictRequestWire::RequestRegistryClaimConfirmation { + version, + id, + params: HandleParams { handle }, + } => ( + version, + id, + Command::RequestRegistryClaimConfirmation { handle }, + ), StrictRequestWire::Who { version, id, diff --git a/crates/omachat-proto/tests/ipc.rs b/crates/omachat-proto/tests/ipc.rs index ca89e19..1eaeb54 100644 --- a/crates/omachat-proto/tests/ipc.rs +++ b/crates/omachat-proto/tests/ipc.rs @@ -39,17 +39,25 @@ fn oversized_malformed_and_unknown_fields_fail_boundedly() { ); assert_eq!(decoder.push(b"not-json\n"), Err(IpcError::MalformedJson)); assert_eq!( - decoder.push(b"{\"version\":1,\"id\":\"x\",\"method\":\"status\",\"extra\":true}\n"), + decoder.push(b"{\"version\":2,\"id\":\"x\",\"method\":\"status\",\"extra\":true}\n"), Err(IpcError::MalformedJson) ); } #[test] fn hello_negotiation_is_explicit() { - assert_eq!(negotiate(1, 1).expect("compatible").version, VERSION); + assert_eq!(negotiate(2, 2).expect("compatible").version, VERSION); + assert_eq!(negotiate(1, 2).expect("ranged").version, VERSION); + assert!( + matches!( + negotiate(1, 1), + Err(IpcError::VersionMismatch { supported: 2, .. }) + ), + "version 1 destructive semantics are gone" + ); assert!(matches!( - negotiate(2, 3), - Err(IpcError::VersionMismatch { supported: 1, .. }) + negotiate(3, 4), + Err(IpcError::VersionMismatch { supported: 2, .. }) )); } @@ -61,46 +69,46 @@ fn requests_preserve_the_flat_wire_format() { minimum_version: 1, maximum_version: 2, }, - r#"{"version":1,"id":"request","method":"hello","params":{"minimum_version":1,"maximum_version":2}}"#, + r#"{"version":2,"id":"request","method":"hello","params":{"minimum_version":1,"maximum_version":2}}"#, ), ( Command::Status, - r#"{"version":1,"id":"request","method":"status"}"#, + r#"{"version":2,"id":"request","method":"status"}"#, ), ( Command::Fingerprint, - r#"{"version":1,"id":"request","method":"fingerprint"}"#, + r#"{"version":2,"id":"request","method":"fingerprint"}"#, ), ( Command::Join { geohash: "u4pruy".into(), }, - r#"{"version":1,"id":"request","method":"join","params":{"geohash":"u4pruy"}}"#, + r#"{"version":2,"id":"request","method":"join","params":{"geohash":"u4pruy"}}"#, ), ( Command::Leave { geohash: "u4pruy".into(), }, - r#"{"version":1,"id":"request","method":"leave","params":{"geohash":"u4pruy"}}"#, + r#"{"version":2,"id":"request","method":"leave","params":{"geohash":"u4pruy"}}"#, ), ( Command::Send { conversation: "general".into(), text: "hello".into(), }, - r#"{"version":1,"id":"request","method":"send","params":{"conversation":"general","text":"hello"}}"#, + r#"{"version":2,"id":"request","method":"send","params":{"conversation":"general","text":"hello"}}"#, ), ( Command::Who { geohash: "u4pruy".into(), }, - r#"{"version":1,"id":"request","method":"who","params":{"geohash":"u4pruy"}}"#, + r#"{"version":2,"id":"request","method":"who","params":{"geohash":"u4pruy"}}"#, ), ( Command::Block { public_key: "pubkey".into(), }, - r#"{"version":1,"id":"request","method":"block","params":{"public_key":"pubkey"}}"#, + r#"{"version":2,"id":"request","method":"block","params":{"public_key":"pubkey"}}"#, ), ( Command::JoinRoom { @@ -108,7 +116,7 @@ fn requests_preserve_the_flat_wire_format() { group_id: "omarchy".into(), invite_code: None, }, - r#"{"version":1,"id":"request","method":"join-room","params":{"relay":"wss://rooms.example","group_id":"omarchy"}}"#, + r#"{"version":2,"id":"request","method":"join-room","params":{"relay":"wss://rooms.example","group_id":"omarchy"}}"#, ), ( Command::JoinRoom { @@ -116,37 +124,37 @@ fn requests_preserve_the_flat_wire_format() { group_id: "omarchy".into(), invite_code: Some("welcome".into()), }, - r#"{"version":1,"id":"request","method":"join-room","params":{"relay":"wss://rooms.example","group_id":"omarchy","invite_code":"welcome"}}"#, + r#"{"version":2,"id":"request","method":"join-room","params":{"relay":"wss://rooms.example","group_id":"omarchy","invite_code":"welcome"}}"#, ), ( Command::LeaveRoom { relay: "wss://rooms.example".into(), group_id: "omarchy".into(), }, - r#"{"version":1,"id":"request","method":"leave-room","params":{"relay":"wss://rooms.example","group_id":"omarchy"}}"#, + r#"{"version":2,"id":"request","method":"leave-room","params":{"relay":"wss://rooms.example","group_id":"omarchy"}}"#, ), ( Command::ListRooms, - r#"{"version":1,"id":"request","method":"list-rooms"}"#, + r#"{"version":2,"id":"request","method":"list-rooms"}"#, ), ( Command::RoomMembers { relay: "wss://rooms.example".into(), group_id: "omarchy".into(), }, - r#"{"version":1,"id":"request","method":"room-members","params":{"relay":"wss://rooms.example","group_id":"omarchy"}}"#, + r#"{"version":2,"id":"request","method":"room-members","params":{"relay":"wss://rooms.example","group_id":"omarchy"}}"#, ), ( Command::Panic { confirmation: "confirm".into(), }, - r#"{"version":1,"id":"request","method":"panic","params":{"confirmation":"confirm"}}"#, + r#"{"version":2,"id":"request","method":"panic","params":{"confirmation":"confirm"}}"#, ), ( Command::Subscribe { topics: vec![Topic::Status, Topic::Messages], }, - r#"{"version":1,"id":"request","method":"subscribe","params":{"topics":["status","messages"]}}"#, + r#"{"version":2,"id":"request","method":"subscribe","params":{"topics":["status","messages"]}}"#, ), ]; @@ -168,7 +176,7 @@ fn requests_preserve_the_flat_wire_format() { #[test] fn request_fields_may_arrive_in_any_order() { let request = serde_json::from_str::( - r#"{"params":{"text":"hello","conversation":"general"},"method":"send","id":"request","version":1}"#, + r#"{"params":{"text":"hello","conversation":"general"},"method":"send","id":"request","version":2}"#, ) .expect("reordered request deserializes"); assert_eq!( @@ -187,15 +195,15 @@ fn request_fields_may_arrive_in_any_order() { #[test] fn requests_reject_noncanonical_arms_and_fields() { let invalid = [ - r#"{"version":1,"id":"x","method":"send"}"#, - r#"{"version":1,"id":"x","method":"send","params":null}"#, - r#"{"version":1,"id":"x","method":"status","params":null}"#, - r#"{"version":1,"id":"x","method":"status","params":{"conversation":"general","text":"hello"}}"#, - r#"{"version":1,"id":"x","method":"send","params":{"conversation":"general","text":"hello","extra":true}}"#, - r#"{"version":1,"id":"x","method":"send","params":{"conversation":"general","conversation":"other","text":"hello"}}"#, - r#"{"version":1,"id":"x","method":"status","extra":true}"#, - r#"{"version":1,"id":"x","method":"status","method":"status"}"#, - r#"{"version":1,"id":"x","method":"unknown"}"#, + r#"{"version":2,"id":"x","method":"send"}"#, + r#"{"version":2,"id":"x","method":"send","params":null}"#, + r#"{"version":2,"id":"x","method":"status","params":null}"#, + r#"{"version":2,"id":"x","method":"status","params":{"conversation":"general","text":"hello"}}"#, + r#"{"version":2,"id":"x","method":"send","params":{"conversation":"general","text":"hello","extra":true}}"#, + r#"{"version":2,"id":"x","method":"send","params":{"conversation":"general","conversation":"other","text":"hello"}}"#, + r#"{"version":2,"id":"x","method":"status","extra":true}"#, + r#"{"version":2,"id":"x","method":"status","method":"status"}"#, + r#"{"version":2,"id":"x","method":"unknown"}"#, ]; for wire in invalid { @@ -218,7 +226,7 @@ fn responses_preserve_the_flat_wire_format_and_null_result() { let encoded = serde_json::to_string(&response).expect("response serializes"); assert_eq!( encoded, - r#"{"version":1,"id":"response-1","status":"ok","result":null}"# + r#"{"version":2,"id":"response-1","status":"ok","result":null}"# ); assert_eq!( serde_json::from_str::(&encoded).expect("null result deserializes"), @@ -237,11 +245,11 @@ fn responses_preserve_the_flat_wire_format_and_null_result() { }; assert_eq!( serde_json::to_string(&error).expect("error serializes"), - r#"{"version":1,"id":"response-2","status":"error","error":{"code":"unavailable","message":"offline"}}"# + r#"{"version":2,"id":"response-2","status":"error","error":{"code":"unavailable","message":"offline"}}"# ); assert_eq!( serde_json::from_str::( - r#"{"error":{"message":"offline","code":"unavailable"},"status":"error","id":"response-2","version":1}"# + r#"{"error":{"message":"offline","code":"unavailable"},"status":"error","id":"response-2","version":2}"# ) .expect("reordered response deserializes"), error @@ -251,18 +259,18 @@ fn responses_preserve_the_flat_wire_format_and_null_result() { #[test] fn responses_reject_missing_wrong_unknown_and_duplicate_arms() { let invalid = [ - r#"{"version":1,"id":"x","status":"ok"}"#, - r#"{"version":1,"id":"x","status":"ok","error":{"code":"internal","message":"failed"}}"#, - r#"{"version":1,"id":"x","status":"ok","result":null,"error":{"code":"internal","message":"failed"}}"#, - r#"{"version":1,"id":"x","status":"error","result":null}"#, - r#"{"version":1,"id":"x","status":"error","error":{"code":"internal","message":"failed"},"result":null}"#, - r#"{"version":1,"id":"x","status":"error"}"#, - r#"{"version":1,"id":"x","status":"error","error":null}"#, - r#"{"version":1,"id":"x","status":"error","error":{"code":"internal","message":"failed","extra":true}}"#, - r#"{"version":1,"id":"x","status":"ok","result":null,"extra":true}"#, - r#"{"version":1,"id":"x","status":"ok","result":null,"result":null}"#, - r#"{"version":1,"id":"x","status":"ok","status":"ok","result":null}"#, - r#"{"version":1,"id":"x","status":"unknown","result":null}"#, + r#"{"version":2,"id":"x","status":"ok"}"#, + r#"{"version":2,"id":"x","status":"ok","error":{"code":"internal","message":"failed"}}"#, + r#"{"version":2,"id":"x","status":"ok","result":null,"error":{"code":"internal","message":"failed"}}"#, + r#"{"version":2,"id":"x","status":"error","result":null}"#, + r#"{"version":2,"id":"x","status":"error","error":{"code":"internal","message":"failed"},"result":null}"#, + r#"{"version":2,"id":"x","status":"error"}"#, + r#"{"version":2,"id":"x","status":"error","error":null}"#, + r#"{"version":2,"id":"x","status":"error","error":{"code":"internal","message":"failed","extra":true}}"#, + r#"{"version":2,"id":"x","status":"ok","result":null,"extra":true}"#, + r#"{"version":2,"id":"x","status":"ok","result":null,"result":null}"#, + r#"{"version":2,"id":"x","status":"ok","status":"ok","result":null}"#, + r#"{"version":2,"id":"x","status":"unknown","result":null}"#, ]; for wire in invalid { @@ -272,3 +280,37 @@ fn responses_reject_missing_wrong_unknown_and_duplicate_arms() { ); } } + +#[test] +fn confirmation_request_commands_round_trip() { + let mut decoder = RequestDecoder::default(); + let requests = decoder + .push(b"{\"version\":2,\"id\":\"1\",\"method\":\"request-panic-confirmation\"}\n") + .expect("decode panic confirmation request"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].command, Command::RequestPanicConfirmation); + + let requests = decoder + .push( + b"{\"version\":2,\"id\":\"2\",\"method\":\"request-registry-claim-confirmation\",\"params\":{\"handle\":\"tom\"}}\n", + ) + .expect("decode claim confirmation request"); + assert_eq!( + requests[0].command, + Command::RequestRegistryClaimConfirmation { + handle: "tom".into() + } + ); + + let encoded = encode_line(&Request { + version: VERSION, + id: "3".into(), + command: Command::RequestRegistryClaimConfirmation { + handle: "tom".into(), + }, + }) + .expect("encode claim confirmation request"); + let text = std::str::from_utf8(&encoded).expect("utf8 line"); + assert!(text.contains("\"method\":\"request-registry-claim-confirmation\"")); + assert!(text.contains("\"handle\":\"tom\"")); +} diff --git a/crates/omachat-proto/tests/nip65_ipc_wire.rs b/crates/omachat-proto/tests/nip65_ipc_wire.rs index 2597252..4801047 100644 --- a/crates/omachat-proto/tests/nip65_ipc_wire.rs +++ b/crates/omachat-proto/tests/nip65_ipc_wire.rs @@ -11,7 +11,7 @@ fn decode(line: &str) -> Command { #[test] fn nip65_publication_is_a_strict_parameterless_command() { - let command = decode(r#"{"version":1,"id":"publish","method":"publish-nip65-relays"}"#); + let command = decode(r#"{"version":2,"id":"publish","method":"publish-nip65-relays"}"#); assert_eq!(command, Command::PublishNip65Relays); let encoded = encode_line(&omachat_proto::ipc::Request { @@ -22,13 +22,13 @@ fn nip65_publication_is_a_strict_parameterless_command() { .expect("encode request"); assert_eq!( std::str::from_utf8(&encoded).expect("UTF-8 request"), - "{\"version\":1,\"id\":\"publish\",\"method\":\"publish-nip65-relays\"}\n" + "{\"version\":2,\"id\":\"publish\",\"method\":\"publish-nip65-relays\"}\n" ); let mut decoder = RequestDecoder::default(); assert_eq!( decoder.push( - b"{\"version\":1,\"id\":\"publish\",\"method\":\"publish-nip65-relays\",\"params\":{}}\n" + b"{\"version\":2,\"id\":\"publish\",\"method\":\"publish-nip65-relays\",\"params\":{}}\n" ), Err(IpcError::MalformedJson) ); diff --git a/crates/omachat-tui/Cargo.toml b/crates/omachat-tui/Cargo.toml index 7b1ed45..26c10c5 100644 --- a/crates/omachat-tui/Cargo.toml +++ b/crates/omachat-tui/Cargo.toml @@ -13,8 +13,9 @@ path = "src/main.rs" [dependencies] omachat-ctl = { path = "../omachat-ctl", version = "=0.0.1" } omachat-proto = { path = "../omachat-proto", version = "=0.0.1" } +ratatui = { version = "=0.30.2", default-features = false, features = ["std", "crossterm"] } serde_json = "=1.0.151" -tokio = { version = "=1.53.1", features = ["io-std", "io-util", "macros", "rt-multi-thread", "signal"] } +tokio = { version = "=1.53.1", features = ["io-std", "io-util", "macros", "rt-multi-thread", "signal", "sync"] } [lints] workspace = true diff --git a/crates/omachat-tui/src/lib.rs b/crates/omachat-tui/src/lib.rs index 5255875..fd1c4eb 100644 --- a/crates/omachat-tui/src/lib.rs +++ b/crates/omachat-tui/src/lib.rs @@ -1,6 +1,22 @@ //! Deterministic ANSI-16 terminal model and command mapping. -use omachat_proto::ipc::Command; +use omachat_proto::ipc::{Command, Event, Topic}; +use ratatui::{ + buffer::Buffer, + layout::{Constraint, Layout, Position, Rect}, + style::{Color, Modifier, Style}, + widgets::Widget, +}; +use serde_json::Value; + +/// Layouts below either bound collapse to the single-column presentation. +const NARROW_WIDTH: u16 = 30; +const NARROW_HEIGHT: u16 = 8; +/// Sidebar bounds applied to one third of the available width. +const SIDEBAR_MINIMUM: u16 = 18; +const SIDEBAR_MAXIMUM: u16 = 28; +/// Columns the `"> "` prompt occupies before composed text starts. +const PROMPT_WIDTH: u16 = 2; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum DeliveryState { @@ -22,6 +38,7 @@ impl DeliveryState { #[derive(Clone, Debug, Eq, PartialEq)] pub struct Message { + pub id: String, pub sender: String, pub text: String, pub outgoing: bool, @@ -42,14 +59,26 @@ pub enum InputMode { Scroll, } +impl InputMode { + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Compose => "Compose", + Self::Scroll => "Scroll", + } + } +} + pub struct UiModel { pub conversations: Vec, pub selected: usize, + pub scroll_offset: usize, pub input: String, pub input_mode: InputMode, pub connected: bool, pub status: String, pub security_notice_pending: bool, + pub panic_confirmation_pending: bool, } impl Default for UiModel { @@ -57,95 +86,356 @@ impl Default for UiModel { Self { conversations: Vec::new(), selected: 0, + scroll_offset: 0, input: String::new(), input_mode: InputMode::Compose, connected: false, status: "detached".into(), security_notice_pending: true, + panic_confirmation_pending: false, + } + } +} + +/// Every style below resolves to one of the sixteen ANSI colours, so the +/// rendered buffer never carries a truecolor or extended-palette cell. +fn heading_style() -> Style { + Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD) +} + +fn title_style() -> Style { + Style::new().fg(Color::White).add_modifier(Modifier::BOLD) +} + +fn sidebar_style() -> Style { + Style::new().fg(Color::White) +} + +fn status_style() -> Style { + Style::new().fg(Color::Black).bg(Color::White) +} + +fn prompt_style() -> Style { + Style::new().fg(Color::Cyan) +} + +impl Widget for &UiModel { + fn render(self, area: Rect, buffer: &mut Buffer) { + if area.width < NARROW_WIDTH || area.height < NARROW_HEIGHT { + self.render_narrow(area, buffer); + } else { + self.render_wide(area, buffer); } } } impl UiModel { - #[must_use] - pub fn render(&self, width: u16, height: u16) -> String { - if width < 30 || height < 8 { - return self.render_narrow(width, height); + /// Replace cached messages with the daemon snapshot while retaining the draft. + pub fn apply_snapshot(&mut self, snapshot: &Value) { + let selected = self.conversations.get(self.selected).map(|c| c.id.clone()); + self.conversations.clear(); + self.selected = 0; + self.scroll_offset = 0; + if let Some(status) = snapshot.get("status") { + self.apply_status(status); } - let sidebar_width = usize::from(width / 3).clamp(18, 28); - let content_width = usize::from(width).saturating_sub(sidebar_width + 3); - let mut lines = Vec::with_capacity(usize::from(height)); - lines.push(format!( - "\x1b[1;36m{}\x1b[0m│\x1b[1;37m {}\x1b[0m", - fit(" Conversations", sidebar_width), - fit(self.selected_title(), content_width) - )); - let body_rows = usize::from(height).saturating_sub(3); - let messages = self.selected_messages(); - for row in 0..body_rows { - let conversation = self.conversations.get(row).map_or_else( - || " ".repeat(sidebar_width), - |conversation| { - let marker = if row == self.selected { ">" } else { " " }; - let unread = if conversation.unread == 0 { - String::new() - } else { - format!(" ({})", conversation.unread) - }; - fit( - &format!("{marker} {}{unread}", conversation.title), - sidebar_width, - ) - }, - ); - let message = messages.get(row).map_or_else(String::new, render_message); - lines.push(format!( - "\x1b[37m{conversation}\x1b[0m│ {}", - fit(&message, content_width) - )); + if let Some(messages) = snapshot["messages"].as_array() { + for message in messages { + self.apply_message(message); + } } - lines.push(format!( - "\x1b[30;47m{}\x1b[0m", - fit( - &format!( - " {} | {:?}{}", - self.status, - self.input_mode, - if self.security_notice_pending { - " | relay DMs use mobile-compatible envelopes" - } else { - "" + if let Some(index) = self + .conversations + .iter() + .position(|c| Some(&c.id) == selected.as_ref()) + { + self.selected = index; + } + for conversation in &mut self.conversations { + conversation.unread = 0; + } + self.connected = true; + self.status = "connected | Tab: conversations | Esc: scroll | /help".into(); + } + + pub fn apply_event(&mut self, event: &Event) { + match event.topic { + Topic::Messages | Topic::Delivery => self.apply_message(&event.payload), + Topic::Status => self.apply_status(&event.payload), + Topic::Conversations => { + if let Some(id) = event.payload["conversation"].as_str() { + let index = self.ensure_conversation(id); + if let Some(title) = event.payload["name"].as_str() { + self.conversations[index].title = clean(title); } - ), - usize::from(width) - ) - )); - lines.push(format!( - "\x1b[36m>\x1b[0m {}", - fit(&self.input, usize::from(width).saturating_sub(2)) - )); - lines.join("\n") + } + } + Topic::Presence => { + self.status = format!( + "presence: {}", + clean(event.payload["conversation"].as_str().unwrap_or("peer")) + ); + } + } + } + + fn apply_status(&mut self, status: &Value) { + if let Some(joined) = status["joined_geohashes"].as_array() { + for geohash in joined.iter().filter_map(Value::as_str) { + self.ensure_conversation(&format!("#{geohash}")); + } + } } - fn render_narrow(&self, width: u16, height: u16) -> String { - let width = usize::from(width); - let mut lines = vec![format!( - "\x1b[1;36m{}\x1b[0m", - fit(self.selected_title(), width) - )]; - for message in self - .selected_messages() + fn ensure_conversation(&mut self, id: &str) -> usize { + if let Some(index) = self.conversations.iter().position(|c| c.id == id) { + return index; + } + if self.conversations.len() == 128 { + self.conversations.remove(0); + self.selected = self.selected.saturating_sub(1); + } + self.conversations.push(Conversation { + id: id.into(), + title: clean(id), + unread: 0, + messages: Vec::new(), + }); + self.conversations.len() - 1 + } + + fn apply_message(&mut self, payload: &Value) { + let Some(id) = payload["id"].as_str() else { + return; + }; + for conversation in &mut self.conversations { + if let Some(index) = conversation.messages.iter().position(|m| m.id == id) { + if payload["deleted"] == true { + conversation.messages.remove(index); + } else if let Some(delivery) = delivery(payload) { + conversation.messages[index].delivery = Some(delivery); + } + return; + } + } + let (Some(conversation), Some(text)) = + (payload["conversation"].as_str(), payload["text"].as_str()) + else { + return; + }; + if payload["deleted"] == true { + return; + } + let index = self.ensure_conversation(conversation); + let outgoing = payload["outgoing"].as_bool().unwrap_or_else(|| { + payload["delivery"] + .as_str() + .is_some_and(|d| d != "received") + }); + let conversation = &mut self.conversations[index]; + if conversation.messages.len() == 128 { + conversation.messages.remove(0); + } + conversation.messages.push(Message { + id: id.into(), + sender: clean(payload["sender"].as_str().unwrap_or("peer")), + text: clean(text), + outgoing, + delivery: delivery(payload), + }); + if index != self.selected && !outgoing { + conversation.unread = conversation.unread.saturating_add(1); + } + if index == self.selected && self.scroll_offset > 0 { + self.scroll_offset = self.scroll_offset.saturating_add(1); + } + } + + pub fn select_next(&mut self, backwards: bool) { + if self.conversations.is_empty() { + return; + } + let count = self.conversations.len(); + self.selected = if backwards { + (self.selected + count - 1) % count + } else { + (self.selected + 1) % count + }; + self.conversations[self.selected].unread = 0; + self.scroll_offset = 0; + } + + pub fn scroll(&mut self, older: bool, rows: usize) { + self.scroll_offset = if older { + self.scroll_offset + .saturating_add(rows) + .min(self.selected_messages().len().saturating_sub(1)) + } else { + self.scroll_offset.saturating_sub(rows) + }; + } + + fn visible_messages(&self, rows: usize) -> &[Message] { + let messages = self.selected_messages(); + let end = messages.len().saturating_sub(self.scroll_offset); + &messages[end.saturating_sub(rows)..end] + } + /// Two-column presentation: conversation sidebar, message pane, status bar, + /// and prompt. + fn render_wide(&self, area: Rect, buffer: &mut Buffer) { + let [heading, body, status, prompt] = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas(area); + let sidebar_width = (area.width / 3).clamp(SIDEBAR_MINIMUM, SIDEBAR_MAXIMUM); + let [sidebar, divider, content] = Layout::horizontal([ + Constraint::Length(sidebar_width), + Constraint::Length(1), + Constraint::Min(0), + ]) + .areas(area); + + write_line( + buffer, + row(sidebar, heading.y), + " Conversations", + heading_style(), + ); + write_line(buffer, row(divider, heading.y), "│", Style::new()); + write_line( + buffer, + row(content, heading.y), + &format!(" {}", self.selected_title()), + title_style(), + ); + + let messages = self.visible_messages(usize::from(body.height)); + for offset in 0..body.height { + let index = usize::from(offset); + let y = body.y.saturating_add(offset); + if let Some(conversation) = self.conversations.get( + index + + self + .selected + .saturating_sub(usize::from(body.height).saturating_sub(1)), + ) { + let marker = if conversation.id == self.conversations[self.selected].id { + ">" + } else { + " " + }; + let unread = if conversation.unread == 0 { + String::new() + } else { + format!(" ({})", conversation.unread) + }; + write_line( + buffer, + row(sidebar, y), + &format!("{marker} {}{unread}", conversation.title), + sidebar_style(), + ); + } + write_line(buffer, row(divider, y), "│", Style::new()); + if let Some(message) = messages.get(index) { + write_line( + buffer, + row(content, y), + &format!(" {}", render_message(message)), + Style::new(), + ); + } + } + + self.render_status(status, buffer, true); + self.render_prompt(prompt, buffer); + } + + /// Single-column fallback for terminals too small for the sidebar. + fn render_narrow(&self, area: Rect, buffer: &mut Buffer) { + let [heading, body, status, prompt] = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas(area); + + write_line(buffer, heading, self.selected_title(), heading_style()); + for (offset, message) in self + .visible_messages(usize::from(body.height)) .iter() - .take(usize::from(height).saturating_sub(3)) + .enumerate() { - lines.push(fit(&render_message(message), width)); + let Ok(offset) = u16::try_from(offset) else { + break; + }; + write_line( + buffer, + row(body, body.y.saturating_add(offset)), + &render_message(message), + Style::new(), + ); + } + + self.render_status(status, buffer, false); + self.render_prompt(prompt, buffer); + } + + fn render_status(&self, area: Rect, buffer: &mut Buffer, detailed: bool) { + if area.is_empty() { + return; } - while lines.len() < usize::from(height).saturating_sub(2) { - lines.push(" ".repeat(width)); + buffer.set_style(area, status_style()); + let text = if detailed { + format!( + " {} | {}{}", + self.status, + self.input_mode.label(), + if self.security_notice_pending { + " | /help for controls" + } else { + "" + } + ) + } else { + self.status.clone() + }; + write_line(buffer, area, &text, status_style()); + } + + fn render_prompt(&self, area: Rect, buffer: &mut Buffer) { + if area.is_empty() { + return; } - lines.push(format!("\x1b[7m{}\x1b[0m", fit(&self.status, width))); - lines.push(fit(&format!("> {}", self.input), width)); - lines.join("\n") + write_line(buffer, area, ">", prompt_style()); + let input = Rect { + x: area.x.saturating_add(PROMPT_WIDTH), + width: area.width.saturating_sub(PROMPT_WIDTH), + ..area + }; + write_line(buffer, input, &self.input, Style::new()); + } + + /// Where the caret belongs: immediately after the prompt and any text + /// already composed. The client is still line-oriented, so the terminal + /// echoes typed characters at the caret and they must land on the prompt + /// row rather than wherever the last buffer diff happened to end. + #[must_use] + pub fn prompt_cursor(&self, area: Rect) -> Position { + if area.is_empty() { + return Position::new(area.x, area.y); + } + let composed = u16::try_from(self.input.chars().count()).unwrap_or(u16::MAX); + let x = area + .x + .saturating_add(PROMPT_WIDTH) + .saturating_add(composed) + .min(area.right().saturating_sub(1)); + Position::new(x, area.bottom().saturating_sub(1)) } fn selected_title(&self) -> &str { @@ -163,6 +453,27 @@ impl UiModel { } } +/// One row of `area` at absolute row `y`, empty when `y` falls outside. +fn row(area: Rect, y: u16) -> Rect { + if area.height == 0 || y < area.y || y >= area.bottom() { + return Rect::new(area.x, area.y, area.width, 0); + } + Rect { + y, + height: 1, + ..area + } +} + +/// Writes `text` clipped to `area`, which may legitimately be empty when the +/// terminal is smaller than the layout wants. +fn write_line(buffer: &mut Buffer, area: Rect, text: &str, style: Style) { + if area.is_empty() { + return; + } + buffer.set_stringn(area.x, area.y, text, usize::from(area.width), style); +} + fn render_message(message: &Message) -> String { let delivery = message.delivery.map_or("", DeliveryState::glyph); if message.outgoing { @@ -172,13 +483,6 @@ fn render_message(message: &Message) -> String { } } -fn fit(value: &str, width: usize) -> String { - let mut fitted = value.chars().take(width).collect::(); - let length = fitted.chars().count(); - fitted.extend(std::iter::repeat_n(' ', width.saturating_sub(length))); - fitted -} - pub fn parse_input( input: &str, current_conversation: Option<&str>, @@ -246,6 +550,23 @@ pub fn parse_input( })) } +// No peer-provided terminal control characters reach the renderer. +fn clean(text: &str) -> String { + text.chars() + .filter(|c| !c.is_control()) + .take(4096) + .collect() +} + +fn delivery(payload: &Value) -> Option { + match payload["delivery"].as_str()? { + "queued" | "created" => Some(DeliveryState::Queued), + "stored" => Some(DeliveryState::Stored), + "failed" => Some(DeliveryState::Failed), + _ => None, + } +} + #[cfg(test)] mod room_command_tests { use super::*; diff --git a/crates/omachat-tui/src/main.rs b/crates/omachat-tui/src/main.rs index d318963..e417d71 100644 --- a/crates/omachat-tui/src/main.rs +++ b/crates/omachat-tui/src/main.rs @@ -1,14 +1,40 @@ use omachat_ctl::{Client, DEFAULT_TIMEOUT}; -use omachat_proto::ipc::ResponseOutcome; -use omachat_tui::{UiModel, parse_input}; +use omachat_proto::ipc::{Command, ResponseOutcome, Topic}; +use omachat_tui::{InputMode, UiModel, parse_input}; +use ratatui::{ + Terminal, TerminalOptions, Viewport, + backend::{Backend, ClearType, CrosstermBackend}, + crossterm::{ + cursor::Show, + event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, + }, + layout::Rect, +}; use std::{ env, ffi::OsStr, - io::{self, IsTerminal, Write}, + io::{self, IsTerminal, Stdout}, path::PathBuf, process::ExitCode, + sync::atomic::{AtomicBool, Ordering}, + thread, + time::Duration, +}; +use tokio::{ + io::{AsyncBufReadExt, BufReader}, + sync::mpsc, }; -use tokio::io::{AsyncBufReadExt, BufReader}; + +/// Geometry used when stdout is not a terminal, preserving the fixed frame the +/// client has always written to a pipe. +const FALLBACK_WIDTH: u16 = 80; +const FALLBACK_HEIGHT: u16 = 24; +/// Terminal events are read on a blocking thread; this bounds the handover. +const EVENT_QUEUE: usize = 64; + +type ClientTerminal = Terminal>; #[tokio::main] async fn main() -> ExitCode { @@ -24,49 +50,285 @@ async fn main() -> ExitCode { return ExitCode::from(2); } }; - let mut client = match Client::connect(socket, DEFAULT_TIMEOUT).await { - Ok(client) => client, + let mut model = UiModel::default(); + let mut terminate = + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(signal) => signal, + Err(error) => { + eprintln!("{error}"); + return ExitCode::from(4); + } + }; + let mut interrupt = + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) { + Ok(signal) => signal, + Err(error) => { + eprintln!("{error}"); + return ExitCode::from(4); + } + }; + let interactive = io::stdout().is_terminal(); + let _guard = match TerminalGuard::enter(interactive) { + Ok(guard) => guard, Err(error) => { eprintln!("{error}"); - return ExitCode::from(3); + return ExitCode::from(4); } }; - let mut model = UiModel { - connected: true, - status: "connected — /join /leave /who /block /send /join-room /leave-room /rooms /panic /detach".into(), - ..UiModel::default() + let mut terminal = match build_terminal(interactive) { + Ok(terminal) => terminal, + Err(error) => { + eprintln!("{error}"); + return ExitCode::from(4); + } }; - let _terminal = TerminalGuard::enter(); - let mut input = BufReader::new(tokio::io::stdin()).lines(); + tokio::select! { + _ = terminate.recv() => {}, + _ = interrupt.recv() => {}, + _ = attached(&mut terminal, &socket, &mut model, interactive) => {}, + } + ExitCode::SUCCESS +} + +async fn attached( + terminal: &mut ClientTerminal, + socket: &std::path::Path, + model: &mut UiModel, + interactive: bool, +) { + let mut keyboard = spawn_event_reader(interactive); + let mut client: Option = None; + let mut daemon_events: Option> = None; + let mut retry = tokio::time::interval(Duration::from_secs(1)); + retry.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut retry_at = tokio::time::Instant::now(); + let mut backoff = Duration::from_secs(1); loop { - print!("\x1b[H\x1b[2J{}", model.render(80, 24)); - let _ = io::stdout().flush(); - let Ok(Some(line)) = input.next_line().await else { - break; - }; - if matches!(line.trim(), "/quit" | "/detach") { - break; + if !model.connected && client.is_some() { + client = None; + daemon_events = None; + retry_at = tokio::time::Instant::now(); + } + if draw(terminal, model).is_err() { + return; } - let current = model - .conversations - .get(model.selected) - .map(|conversation| conversation.id.as_str()); - match parse_input(&line, current) { - Ok(Some(command)) => match client.request(command).await { - Ok(response) => match response.outcome { - ResponseOutcome::Ok { result } => { - model.status = result.to_string(); - model.security_notice_pending = false; + tokio::select! { + _ = retry.tick() => { + if client.is_none() && tokio::time::Instant::now() >= retry_at { + match connect(socket).await { + Ok((connected, snapshot, events)) => { + model.apply_snapshot(&snapshot); + client = Some(connected); + daemon_events = Some(events); + backoff = Duration::from_secs(1); + } + Err(error) => { + model.status = format!("reconnecting: {error}"); + retry_at = tokio::time::Instant::now() + backoff; + backoff = (backoff * 2).min(Duration::from_secs(30)); + } } - ResponseOutcome::Error { error } => model.status = error.message, - }, - Err(error) => model.status = format!("disconnected: {error}"), - }, - Ok(None) => {} - Err(error) => model.status = error, + } + } + event = async { daemon_events.as_mut().expect("connected receiver").recv().await }, if daemon_events.is_some() => { + if let Some(event) = event { model.apply_event(&event); } + else { + client = None; + daemon_events = None; + model.connected = false; + model.status = "disconnected; reconnecting".into(); + retry_at = tokio::time::Instant::now(); + } + } + event = keyboard.recv() => { + let Some(event) = event else { return; }; + match event { + Input::Line(line) => { + if !handle_line(&mut client, model, &line).await { return; } + } + Input::Terminal(Event::Resize(..)) => { + if repaint(terminal).is_err() { return; } + } + Input::Terminal(Event::Key(key)) if key.kind == KeyEventKind::Press => { + let control = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Char('c' | 'd') if control => return, + KeyCode::Tab => model.select_next(false), + KeyCode::BackTab => model.select_next(true), + KeyCode::Esc => { + model.input_mode = if model.input_mode == InputMode::Compose { InputMode::Scroll } else { InputMode::Compose }; + } + KeyCode::PageUp => model.scroll(true, 10), + KeyCode::PageDown => model.scroll(false, 10), + KeyCode::Up if model.input_mode == InputMode::Scroll => model.scroll(true, 1), + KeyCode::Down if model.input_mode == InputMode::Scroll => model.scroll(false, 1), + KeyCode::Char('i') if model.input_mode == InputMode::Scroll => model.input_mode = InputMode::Compose, + KeyCode::Char(character) if model.input_mode == InputMode::Compose && !control && model.input.len() + character.len_utf8() <= 4096 => model.input.push(character), + KeyCode::Backspace if model.input_mode == InputMode::Compose => { model.input.pop(); } + KeyCode::Enter if model.input_mode == InputMode::Compose => { + let line = model.input.clone(); + if !handle_line(&mut client, model, &line).await { return; } + } + _ => {} + } + } + _ => {} + } + } } } - ExitCode::SUCCESS +} + +async fn connect( + socket: &std::path::Path, +) -> Result< + ( + Client, + serde_json::Value, + mpsc::Receiver, + ), + omachat_ctl::ClientError, +> { + let mut client = Client::connect(socket, DEFAULT_TIMEOUT).await?; + let (snapshot, events) = client + .subscribe(vec![ + Topic::Status, + Topic::Conversations, + Topic::Messages, + Topic::Presence, + Topic::Delivery, + ]) + .await?; + Ok((client, snapshot, events)) +} + +async fn handle_line(client: &mut Option, model: &mut UiModel, line: &str) -> bool { + if matches!(line.trim(), "/quit" | "/detach") { + return false; + } + if line.trim() == "/help" { + model.status = "Tab/Shift-Tab: chat | Esc/i: scroll/compose | PgUp/PgDn | /join HASH | /send dm:KEY TEXT | /detach".into(); + model.input.clear(); + return true; + } + if let Some(client) = client.as_mut() { + submit(client, model, line).await + } else { + model.status = "disconnected; draft kept, waiting to reconnect".into(); + true + } +} + +enum Input { + Terminal(Event), + Line(String), +} + +/// Terminal events block a dedicated thread and reach the runtime over a +/// bounded channel, which keeps the client's only new dependency on crossterm +/// itself rather than an async event-stream stack. +fn spawn_event_reader(interactive: bool) -> mpsc::Receiver { + let (sender, receiver) = mpsc::channel(EVENT_QUEUE); + if interactive { + thread::spawn(move || { + while !sender.is_closed() { + match event::poll(Duration::from_millis(100)) { + Ok(true) => match event::read() { + Ok(event) => { + if sender.blocking_send(Input::Terminal(event)).is_err() { + break; + } + } + Err(_) => break, + }, + Ok(false) => {} + Err(_) => break, + } + } + }); + } else { + tokio::spawn(async move { + let mut lines = BufReader::new(tokio::io::stdin()).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if sender.send(Input::Line(line)).await.is_err() { + break; + } + } + }); + } + receiver +} + +fn draw(terminal: &mut ClientTerminal, model: &UiModel) -> io::Result<()> { + terminal.draw(|frame| { + let area = frame.area(); + frame.render_widget(&*model, area); + frame.set_cursor_position(model.prompt_cursor(area)); + })?; + Ok(()) +} + +/// Wipes the screen and blanks the previous buffer so the next draw is a full +/// repaint. `Terminal::clear` is unusable here because it first queries the +/// cursor position, which fails outright on a pipe. +fn repaint(terminal: &mut ClientTerminal) -> io::Result<()> { + terminal.backend_mut().clear_region(ClearType::All)?; + terminal.swap_buffers(); + Ok(()) +} + +/// Applies one composed line. Returns false when the client should detach, +/// which never stops the daemon. +async fn submit(client: &mut Client, model: &mut UiModel, line: &str) -> bool { + if matches!(line.trim(), "/quit" | "/detach") { + return false; + } + let current = model + .conversations + .get(model.selected) + .map(|conversation| conversation.id.as_str()); + match parse_input(line, current) { + Ok(Some(Command::Panic { .. })) if !model.panic_confirmation_pending => { + model.status = + "Panic erases local keys/history only. Repeat /panic ERASE to proceed.".into(); + model.panic_confirmation_pending = true; + } + Ok(Some(command)) => match omachat_ctl::request_with_confirmation(client, command).await { + Ok(response) => match response.outcome { + ResponseOutcome::Ok { result } => { + model.panic_confirmation_pending = false; + model.input.clear(); + model.status = result.to_string(); + model.security_notice_pending = false; + } + ResponseOutcome::Error { error } => model.status = error.message, + }, + Err(error) => { + model.connected = false; + model.status = + format!("request failed (draft kept; delivery may be unknown): {error}"); + } + }, + Ok(None) => {} + Err(error) => model.status = error, + } + true +} + +/// A terminal drives its own geometry; a pipe keeps the fixed frame so +/// redirected output stays usable. +fn build_terminal(interactive: bool) -> io::Result { + let backend = CrosstermBackend::new(io::stdout()); + if interactive { + Terminal::new(backend) + } else { + Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Fixed(Rect::new(0, 0, FALLBACK_WIDTH, FALLBACK_HEIGHT)), + }, + ) + } } fn socket_path(arguments: &[std::ffi::OsString]) -> Result { @@ -85,21 +347,36 @@ struct TerminalGuard { } impl TerminalGuard { - fn enter() -> Self { - let active = io::stdout().is_terminal(); + fn enter(active: bool) -> io::Result { if active { - print!("\x1b[?1049h\x1b[?25l"); - let _ = io::stdout().flush(); + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + restore_terminal(); + previous(info); + })); + enable_raw_mode()?; + TERMINAL_ACTIVE.store(true, Ordering::SeqCst); + if let Err(error) = execute!(io::stdout(), EnterAlternateScreen) { + restore_terminal(); + return Err(error); + } } - Self { active } + Ok(Self { active }) } } impl Drop for TerminalGuard { fn drop(&mut self) { if self.active { - print!("\x1b[0m\x1b[?25h\x1b[?1049l"); - let _ = io::stdout().flush(); + restore_terminal(); } } } + +static TERMINAL_ACTIVE: AtomicBool = AtomicBool::new(false); +fn restore_terminal() { + if TERMINAL_ACTIVE.swap(false, Ordering::SeqCst) { + let _ = execute!(io::stdout(), Show, LeaveAlternateScreen); + let _ = disable_raw_mode(); + } +} diff --git a/crates/omachat-tui/tests/ui.rs b/crates/omachat-tui/tests/ui.rs index c8f0d00..9fa3588 100644 --- a/crates/omachat-tui/tests/ui.rs +++ b/crates/omachat-tui/tests/ui.rs @@ -1,5 +1,13 @@ use omachat_proto::ipc::Command; -use omachat_tui::{Conversation, DeliveryState, Message, UiModel, parse_input}; +use omachat_tui::{Conversation, DeliveryState, InputMode, Message, UiModel, parse_input}; +use ratatui::{ + Terminal, + backend::TestBackend, + buffer::Buffer, + layout::{Position, Rect}, + style::Color, + widgets::Widget, +}; fn model() -> UiModel { UiModel { @@ -9,12 +17,14 @@ fn model() -> UiModel { unread: 2, messages: vec![ Message { + id: String::new(), sender: "alice".into(), text: "hello".into(), outgoing: false, delivery: None, }, Message { + id: String::new(), sender: "me".into(), text: "queued".into(), outgoing: true, @@ -28,18 +38,118 @@ fn model() -> UiModel { } } +fn draw(width: u16, height: u16) -> Buffer { + let mut buffer = Buffer::empty(Rect::new(0, 0, width, height)); + model().render(buffer.area, &mut buffer); + buffer +} + +fn rows(buffer: &Buffer) -> Vec { + (buffer.area.top()..buffer.area.bottom()) + .map(|y| { + (buffer.area.left()..buffer.area.right()) + .filter_map(|x| buffer.cell(Position::new(x, y))) + .map(|cell| cell.symbol().to_owned()) + .collect() + }) + .collect() +} + +/// The sixteen ANSI colours are the whole permitted palette: no truecolor and +/// no extended-palette index may reach the terminal. +fn is_ansi16(color: Color) -> bool { + !matches!(color, Color::Rgb(..)) && !matches!(color, Color::Indexed(index) if index > 15) +} + #[test] fn eighty_by_twenty_four_and_narrow_layouts_are_bounded_ansi16() { for (width, height) in [(80, 24), (24, 10)] { - let rendered = model().render(width, height); - assert_eq!(rendered.lines().count(), usize::from(height)); - assert!(!rendered.contains("38;2")); - assert!(!rendered.contains("48;2")); - assert!(rendered.contains("#gcpvj")); - assert!(rendered.contains('○')); + let buffer = draw(width, height); + let rendered = rows(&buffer); + assert_eq!(rendered.len(), usize::from(height)); + assert!( + rendered + .iter() + .all(|row| row.chars().count() == usize::from(width)) + ); + for y in buffer.area.top()..buffer.area.bottom() { + for x in buffer.area.left()..buffer.area.right() { + let cell = buffer.cell(Position::new(x, y)).expect("cell inside area"); + assert!( + is_ansi16(cell.fg) && is_ansi16(cell.bg), + "cell ({x},{y}) leaves the ANSI-16 palette: fg={:?} bg={:?}", + cell.fg, + cell.bg + ); + } + } + assert!(rendered.iter().any(|row| row.contains("#gcpvj"))); + assert!(rendered.iter().any(|row| row.contains('○'))); } } +#[test] +fn wide_layout_shows_the_sidebar_and_narrow_layout_drops_it() { + let wide = rows(&draw(80, 24)); + assert!(wide.iter().any(|row| row.contains('│'))); + assert!(wide.iter().any(|row| row.contains("> #gcpvj (2)"))); + assert!(wide.iter().any(|row| row.contains("Conversations"))); + + let narrow = rows(&draw(24, 10)); + assert!(narrow.iter().all(|row| !row.contains('│'))); + assert!(narrow.iter().all(|row| !row.contains("Conversations"))); +} + +#[test] +fn status_bar_reports_mode_and_prompt_holds_input() { + let mut model = model(); + model.input_mode = InputMode::Scroll; + model.input = "typing".into(); + let mut buffer = Buffer::empty(Rect::new(0, 0, 80, 24)); + (&model).render(buffer.area, &mut buffer); + let rendered = rows(&buffer); + assert!( + rendered + .iter() + .any(|row| row.contains("connected | Scroll")) + ); + assert!( + rendered + .last() + .is_some_and(|row| row.starts_with("> typing")) + ); +} + +/// Cooked-mode input echoes at the caret, so the caret has to sit on the prompt +/// row after the composed text or typing lands in the status bar. +#[test] +fn prompt_cursor_tracks_composed_input_on_the_last_row() { + let mut model = model(); + let area = Rect::new(0, 0, 80, 24); + assert_eq!(model.prompt_cursor(area), Position::new(2, 23)); + + model.input = "/rooms".into(); + assert_eq!(model.prompt_cursor(area), Position::new(8, 23)); + + model.input = "x".repeat(200); + assert_eq!(model.prompt_cursor(area), Position::new(79, 23)); + + let narrow = Rect::new(0, 0, 24, 10); + model.input = "hi".into(); + assert_eq!(model.prompt_cursor(narrow), Position::new(4, 9)); +} + +#[test] +fn terminal_draw_renders_the_model_through_a_backend() { + let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("test backend terminal"); + terminal + .draw(|frame| frame.render_widget(&model(), frame.area())) + .expect("draw succeeds"); + let rendered = rows(terminal.backend().buffer()); + assert_eq!(rendered.len(), 24); + assert!(rendered.iter().any(|row| row.contains("#gcpvj"))); +} + #[test] fn messaging_commands_map_to_daemon_requests() { assert!(matches!( @@ -53,3 +163,59 @@ fn messaging_commands_map_to_daemon_requests() { )); assert!(parse_input("hello", None).is_err()); } + +#[test] +fn live_messages_delivery_deletion_and_reattach_are_consistent() { + use omachat_proto::ipc::{Event, Topic, VERSION}; + use serde_json::json; + let mut model = UiModel { + input: "unfinished draft".into(), + ..UiModel::default() + }; + model.apply_snapshot(&json!({"status": {"joined_geohashes": ["gcpvj"]}, "messages": []})); + let mut event = Event { + version: VERSION, + sequence: 1, + topic: Topic::Messages, + payload: json!({"id": "1", "conversation": "dm:alice", "sender": "alice", "text": "hi\u{1b}[2J", "delivery": "received"}), + }; + model.apply_event(&event); + model.apply_event(&event); + assert_eq!(model.conversations[1].messages.len(), 1); + assert_eq!(model.conversations[1].unread, 1); + assert!(!model.conversations[1].messages[0].text.contains('\u{1b}')); + model.select_next(false); + assert_eq!(model.conversations[1].unread, 0); + event.payload = json!({"id": "2", "conversation": "dm:alice", "sender": "you", "text": "reply", "delivery": "queued"}); + model.apply_event(&event); + event.topic = Topic::Delivery; + event.payload = json!({"id": "2", "delivery": "stored"}); + model.apply_event(&event); + assert_eq!( + model.conversations[1].messages[1].delivery, + Some(DeliveryState::Stored) + ); + event.topic = Topic::Messages; + event.payload = json!({"id": "1", "deleted": true}); + model.apply_event(&event); + assert_eq!(model.conversations[1].messages.len(), 1); + model.apply_snapshot(&json!({"messages": [{"id": "2", "conversation": "dm:alice", "text": "reply", "delivery": "stored"}]})); + assert_eq!(model.input, "unfinished draft"); + assert_eq!( + model.conversations[0].messages[0].delivery, + Some(DeliveryState::Stored) + ); +} + +#[test] +fn scrolling_reveals_older_messages_and_conversation_selection_clears_unread() { + use serde_json::json; + let mut model = UiModel::default(); + model.apply_snapshot(&json!({"messages": (0..30).map(|i| json!({"id": i.to_string(), "conversation": "#gcpvj", "sender": "peer", "text": format!("message {i}")})).collect::>()})); + let mut buffer = Buffer::empty(Rect::new(0, 0, 80, 24)); + model.render(buffer.area, &mut buffer); + assert!(rows(&buffer).iter().any(|r| r.contains("message 29"))); + model.scroll(true, 20); + model.render(buffer.area, &mut buffer); + assert!(rows(&buffer).iter().any(|r| r.contains("message 0"))); +} diff --git a/crates/omachatd/Cargo.toml b/crates/omachatd/Cargo.toml index 10b9016..e08b45c 100644 --- a/crates/omachatd/Cargo.toml +++ b/crates/omachatd/Cargo.toml @@ -17,6 +17,7 @@ omachat-proto = { path = "../omachat-proto", version = "=0.0.1" } omachat-registry = { path = "../omachat-registry", version = "=0.0.1" } omachat-registry-transport = { path = "../omachat-registry-transport", version = "=0.0.1" } omachat-store = { path = "../omachat-store", version = "=0.0.1" } +rustix = { version = "=1.1.4", features = ["fs", "process"] } serde = { version = "=1.0.229", features = ["derive"] } serde_json = "=1.0.151" tokio = { version = "=1.53.1", features = ["io-util", "macros", "net", "rt-multi-thread", "signal", "sync"] } diff --git a/crates/omachatd/src/chat_history.rs b/crates/omachatd/src/chat_history.rs new file mode 100644 index 0000000..6e4360e --- /dev/null +++ b/crates/omachatd/src/chat_history.rs @@ -0,0 +1,144 @@ +//! Bounded sealed UI cache. Transport queues remain the delivery authority. +use crate::CoreError; +use omachat_store::{SealedStore, StoreError}; +use serde_json::Value; + +const RECORD: &str = "chat-history-v1"; +const MAX_BYTES: usize = 32 * 1024; +const MAX_MESSAGES: usize = 128; +const MAX_AGE: u64 = 24 * 60 * 60; + +#[derive(Default)] +pub(crate) struct ChatHistory { + messages: Vec, +} + +impl ChatHistory { + pub fn load(store: &SealedStore, now: u64) -> Result { + let messages = match store.read(RECORD) { + Ok(bytes) => { + if bytes.len() > MAX_BYTES { + return Err(CoreError::Encoding); + } + serde_json::from_slice(&bytes).map_err(|_| CoreError::Encoding)? + } + Err(StoreError::RecordNotFound) => Vec::new(), + Err(error) => return Err(CoreError::Store(error)), + }; + let mut history = Self { messages }; + history.expire(now); + history.persist(store)?; + Ok(history) + } + + fn expire(&mut self, now: u64) { + self.messages.retain(|message| { + message["cached_at"] + .as_u64() + .is_some_and(|at| at <= now && now.saturating_sub(at) < MAX_AGE) + }); + } + + pub fn snapshot(&mut self, store: &SealedStore, now: u64) -> Result, CoreError> { + self.expire(now); + self.persist(store)?; + Ok(self.messages.clone()) + } + + pub fn update( + &mut self, + store: &SealedStore, + mut payload: Value, + now: u64, + ) -> Result<(), CoreError> { + self.expire(now); + let Some(id) = payload["id"].as_str() else { + return Ok(()); + }; + let index = self + .messages + .iter() + .position(|message| message["id"].as_str() == Some(id)); + if payload["deleted"].as_bool() == Some(true) { + if let Some(index) = index { + self.messages.remove(index); + } + } else if let Some(index) = index { + // Relay echoes and retry acknowledgements enrich, never duplicate, + // the original message or reset its local retention deadline. + let old = self.messages[index] + .as_object_mut() + .ok_or(CoreError::Encoding)?; + if let Some(delivery) = payload.get("delivery") + && delivery != "received" + { + old.insert("delivery".into(), delivery.clone()); + } + } else if payload["text"].is_string() && payload["conversation"].is_string() { + payload["cached_at"] = now.into(); + self.messages.push(payload); + } + while self.messages.len() > MAX_MESSAGES + || serde_json::to_vec(&self.messages) + .map_err(|_| CoreError::Encoding)? + .len() + > MAX_BYTES + { + self.messages.remove(0); + } + self.persist(store) + } + + pub fn clear(&mut self) { + self.messages.clear(); + } + + fn persist(&self, store: &SealedStore) -> Result<(), CoreError> { + store + .write( + RECORD, + &serde_json::to_vec(&self.messages).map_err(|_| CoreError::Encoding)?, + ) + .map_err(CoreError::Store) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use omachat_store::RequestedProvider; + use serde_json::json; + + #[tokio::test] + async fn sealed_history_restarts_deduplicates_updates_expires_and_stays_bounded() { + let dir = tempfile::tempdir().unwrap(); + let store = SealedStore::open(dir.path(), RequestedProvider::File) + .await + .unwrap(); + let mut history = ChatHistory::load(&store, 100).unwrap(); + history.update(&store, json!({"id":"one", "conversation":"dm:peer", "text":"secret plaintext", "delivery":"queued"}), 100).unwrap(); + history + .update(&store, json!({"id":"one", "delivery":"stored"}), 101) + .unwrap(); + drop(history); + let mut history = ChatHistory::load(&store, 102).unwrap(); + let snapshot = history.snapshot(&store, 102).unwrap(); + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0]["delivery"], "stored"); + let sealed = std::fs::read(dir.path().join("records").join(RECORD)).unwrap(); + assert!(!sealed.windows(16).any(|w| w == b"secret plaintext")); + for i in 0..200 { + history + .update( + &store, + json!({"id":i.to_string(), "conversation":"#gcpvj", "text":"x".repeat(4096)}), + 103, + ) + .unwrap(); + } + let snapshot = history.snapshot(&store, 103).unwrap(); + assert!(snapshot.len() <= MAX_MESSAGES); + assert!(serde_json::to_vec(&snapshot).unwrap().len() <= MAX_BYTES); + assert!(history.snapshot(&store, 103 + MAX_AGE).unwrap().is_empty()); + } +} diff --git a/crates/omachatd/src/confirmation.rs b/crates/omachatd/src/confirmation.rs new file mode 100644 index 0000000..d271fe4 --- /dev/null +++ b/crates/omachatd/src/confirmation.rs @@ -0,0 +1,247 @@ +//! Single-use, TTL-bounded confirmation tokens for destructive commands. +//! +//! A destructive command (`panic`, `claim-registry-handle`) is a two-phase +//! exchange: the client first requests a confirmation, the daemon mints a +//! random token and places it in a 0600 file inside the 0700 +//! `/confirmations/` directory, and the client must echo that +//! token back within the TTL. This replaces the in-band constant `"ERASE"` +//! (a typo guard, not authorization). Same-uid processes can still read the +//! state directory — this is a deliberate-two-step and freshness guarantee +//! layered on the peer-credential gate, not a hard boundary; see SECURITY.md. + +use crate::CoreError; +use std::{ + collections::HashMap, + fs, + io::Write, + os::unix::fs::{OpenOptionsExt, PermissionsExt}, + path::{Path, PathBuf}, + sync::Mutex, +}; + +/// A confirmation token is useless after two minutes: long enough for an +/// interactive `--confirm` round trip, short enough that a token left in the +/// state directory by an abandoned command is not a standing authorization. +pub const CONFIRMATION_TTL_SECONDS: u64 = 120; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ConfirmationAction { + PanicErase, + RegistryClaim { handle: String }, +} + +impl ConfirmationAction { + /// One outstanding token per action kind; a claim token additionally + /// pins the exact handle through the pending-entry comparison. + fn file_name(&self) -> &'static str { + match self { + Self::PanicErase => "panic.token", + Self::RegistryClaim { .. } => "registry-claim.token", + } + } +} + +#[derive(Debug)] +struct PendingToken { + action: ConfirmationAction, + token: String, + expires_at: u64, +} + +#[derive(Debug)] +pub struct IssuedConfirmation { + pub token_path: PathBuf, + pub expires_at: u64, +} + +#[derive(Debug, Eq, PartialEq)] +pub enum ConfirmationError { + Missing, + Mismatch, + Expired, +} + +#[derive(Debug)] +pub struct DestructiveConfirmations { + directory: PathBuf, + pending: Mutex>, +} + +impl DestructiveConfirmations { + #[must_use] + pub fn new(state_directory: &Path) -> Self { + Self { + directory: state_directory.join("confirmations"), + pending: Mutex::new(HashMap::new()), + } + } + + /// Mint a fresh token for `action`, replacing any outstanding token of + /// the same kind. The token travels out of band: the caller learns only + /// the path, and must be able to read the daemon's state directory to + /// obtain the value itself. + pub fn issue( + &self, + action: ConfirmationAction, + now: u64, + ) -> Result { + let mut pending = self.pending.lock().expect("confirmation mutex poisoned"); + let mut bytes = [0_u8; 32]; + getrandom::fill(&mut bytes).map_err(|_| CoreError::Random)?; + let token = hex::encode(bytes); + fs::create_dir_all(&self.directory).map_err(CoreError::Io)?; + fs::set_permissions(&self.directory, fs::Permissions::from_mode(0o700)) + .map_err(CoreError::Io)?; + let token_path = self.directory.join(action.file_name()); + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW) + .open(&token_path) + .map_err(CoreError::Io)?; + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(CoreError::Io)?; + file.write_all(token.as_bytes()).map_err(CoreError::Io)?; + let expires_at = now.saturating_add(CONFIRMATION_TTL_SECONDS); + pending.insert( + action.file_name(), + PendingToken { + action, + token, + expires_at, + }, + ); + Ok(IssuedConfirmation { + token_path, + expires_at, + }) + } + + /// Single use, burn on attempt: the pending entry and the token file are + /// consumed by every redemption attempt for the action kind, matched or + /// not, so a wrong guess costs the outstanding token instead of leaving + /// it available for retries. Tokens are 256-bit random values, so a + /// non-constant-time comparison leaks nothing recoverable within one + /// attempt. + pub fn redeem( + &self, + action: &ConfirmationAction, + presented: &str, + now: u64, + ) -> Result<(), ConfirmationError> { + let mut pending_tokens = self.pending.lock().expect("confirmation mutex poisoned"); + let removed = pending_tokens.remove(action.file_name()); + let _ = fs::remove_file(self.directory.join(action.file_name())); + let Some(pending) = removed else { + return Err(ConfirmationError::Missing); + }; + if pending.expires_at <= now { + return Err(ConfirmationError::Expired); + } + if &pending.action != action || pending.token != presented { + return Err(ConfirmationError::Mismatch); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::{ + CONFIRMATION_TTL_SECONDS, ConfirmationAction, ConfirmationError, DestructiveConfirmations, + }; + use std::os::unix::fs::PermissionsExt; + use tempfile::tempdir; + + #[test] + fn issue_writes_a_private_single_use_token() { + let state = tempdir().expect("state directory"); + let confirmations = DestructiveConfirmations::new(state.path()); + let issued = confirmations + .issue(ConfirmationAction::PanicErase, 1_000) + .expect("issue token"); + assert_eq!(issued.expires_at, 1_000 + CONFIRMATION_TTL_SECONDS); + let mode = std::fs::metadata(&issued.token_path) + .expect("token metadata") + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + let token = std::fs::read_to_string(&issued.token_path).expect("token file"); + assert_eq!(token.len(), 64, "32 random bytes hex encoded"); + + confirmations + .redeem(&ConfirmationAction::PanicErase, &token, 1_010) + .expect("first redemption succeeds"); + assert!(!issued.token_path.exists(), "redemption consumes the file"); + assert_eq!( + confirmations.redeem(&ConfirmationAction::PanicErase, &token, 1_010), + Err(ConfirmationError::Missing), + "tokens are single use" + ); + } + + #[test] + fn wrong_token_burns_the_pending_confirmation() { + let state = tempdir().expect("state directory"); + let confirmations = DestructiveConfirmations::new(state.path()); + let issued = confirmations + .issue(ConfirmationAction::PanicErase, 0) + .expect("issue token"); + assert_eq!( + confirmations.redeem(&ConfirmationAction::PanicErase, "ERASE", 1), + Err(ConfirmationError::Mismatch), + "the legacy constant is no longer a confirmation" + ); + let token = std::fs::read_to_string(&issued.token_path); + assert!( + token.is_err(), + "a failed guess consumes the outstanding token" + ); + } + + #[test] + fn expired_tokens_are_rejected() { + let state = tempdir().expect("state directory"); + let confirmations = DestructiveConfirmations::new(state.path()); + let issued = confirmations + .issue(ConfirmationAction::PanicErase, 100) + .expect("issue token"); + let token = std::fs::read_to_string(&issued.token_path).expect("token file"); + assert_eq!( + confirmations.redeem( + &ConfirmationAction::PanicErase, + &token, + issued.expires_at + 1 + ), + Err(ConfirmationError::Expired) + ); + } + + #[test] + fn claim_tokens_are_bound_to_their_handle() { + let state = tempdir().expect("state directory"); + let confirmations = DestructiveConfirmations::new(state.path()); + let issued = confirmations + .issue( + ConfirmationAction::RegistryClaim { + handle: "tom".into(), + }, + 0, + ) + .expect("issue token"); + let token = std::fs::read_to_string(&issued.token_path).expect("token file"); + assert_eq!( + confirmations.redeem( + &ConfirmationAction::RegistryClaim { + handle: "alice".into() + }, + &token, + 1, + ), + Err(ConfirmationError::Mismatch), + "a token minted for one handle must not confirm another" + ); + } +} diff --git a/crates/omachatd/src/core.rs b/crates/omachatd/src/core.rs index 67b3b66..85657c2 100644 --- a/crates/omachatd/src/core.rs +++ b/crates/omachatd/src/core.rs @@ -201,6 +201,7 @@ const ROOM_HISTORY_WINDOW_SECONDS: u64 = 7 * 24 * 60 * 60; struct CoreInner { store: Arc, + chat_history: Mutex, identity: Mutex>, account: Mutex>, storage_transaction: Mutex<()>, @@ -221,6 +222,7 @@ struct CoreInner { config: Mutex, events: EventHub, sequence: AtomicU64, + confirmations: crate::confirmation::DestructiveConfirmations, } #[derive(Clone)] @@ -290,6 +292,7 @@ impl DaemonCore { events: EventHub, ) -> Result { config.validate()?; + let confirmation_root = state_directory.as_ref().to_owned(); let store = Arc::new( SealedStore::open(&state_directory, config.storage_provider.into()) .await @@ -367,8 +370,10 @@ impl DaemonCore { } else { None }; + let chat_history = crate::chat_history::ChatHistory::load(&store, unix_time()?)?; Ok(Self { inner: Arc::new(CoreInner { + chat_history: Mutex::new(chat_history), store, identity: Mutex::new(Some(identity)), account: Mutex::new(Some(account)), @@ -390,6 +395,9 @@ impl DaemonCore { config: Mutex::new(config), events, sequence: AtomicU64::new(1), + confirmations: crate::confirmation::DestructiveConfirmations::new( + &confirmation_root, + ), }), }) } @@ -1386,7 +1394,7 @@ impl DaemonCore { ); } } - self.inner.events.publish(Event { version: VERSION, sequence: self.inner.sequence.fetch_add(1, Ordering::Relaxed), topic, payload: serde_json::json!({"id": event.id, "conversation": format!("#{}", geohash), "text": content}) }); + self.publish_topic_event(topic, serde_json::json!({"id": event.id, "conversation": format!("#{}", geohash), "sender": event.pubkey, "text": content, "delivery": "received"})); return; } let recipient_secret = { @@ -1775,14 +1783,46 @@ impl DaemonCore { confirmation, } => { let handle = GlobalHandle::parse(&handle).map_err(|_| CoreError::InvalidHandle)?; - if confirmation != handle.as_str() { - return Err(CoreError::RegistryClaimConfirmationRequired); - } + self.inner + .confirmations + .redeem( + &crate::confirmation::ConfirmationAction::RegistryClaim { + handle: handle.as_str().to_owned(), + }, + &confirmation, + unix_time()?, + ) + .map_err(|error| match error { + crate::confirmation::ConfirmationError::Expired => { + CoreError::ConfirmationExpired + } + crate::confirmation::ConfirmationError::Missing + | crate::confirmation::ConfirmationError::Mismatch => { + CoreError::RegistryClaimConfirmationRequired + } + })?; let result = self .claim_configured_registry_handle_active(&handle, unix_time()?) .await?; Ok(registry_claim_value(&handle, &result)) } + Command::RequestPanicConfirmation => { + let issued = self.inner.confirmations.issue( + crate::confirmation::ConfirmationAction::PanicErase, + unix_time()?, + )?; + Ok(confirmation_issue_value(&issued)) + } + Command::RequestRegistryClaimConfirmation { handle } => { + let handle = GlobalHandle::parse(&handle).map_err(|_| CoreError::InvalidHandle)?; + let issued = self.inner.confirmations.issue( + crate::confirmation::ConfirmationAction::RegistryClaim { + handle: handle.as_str().to_owned(), + }, + unix_time()?, + )?; + Ok(confirmation_issue_value(&issued)) + } Command::Who { geohash } => self.who(&geohash), Command::Block { public_key } => self.block(&public_key), Command::JoinRoom { @@ -1793,7 +1833,20 @@ impl DaemonCore { Command::LeaveRoom { relay, group_id } => self.leave_room(&relay, group_id).await, Command::ListRooms => self.list_rooms().await, Command::RoomMembers { relay, group_id } => self.room_members(&relay, group_id).await, - Command::Subscribe { topics } => Ok(serde_json::json!({"topics": topics})), + Command::Subscribe { topics } => { + let messages = if topics.contains(&omachat_proto::ipc::Topic::Messages) { + self.inner + .chat_history + .lock() + .expect("chat history mutex poisoned") + .snapshot(&self.inner.store, unix_time()?)? + } else { + Vec::new() + }; + Ok( + serde_json::json!({"topics": topics, "status": self.status_value()?, "messages": messages}), + ) + } Command::Panic { .. } | Command::Hello { .. } => Err(CoreError::InvalidCommand), } } @@ -2517,9 +2570,23 @@ impl DaemonCore { return None; } let mut outbox = NostrOutbox::load(&self.inner.store, now).ok()?; - outbox + let state = outbox .record_transport_attempt(id, omachat_store::OutboxTransport::Nostr, outcome, now) - .ok() + .ok(); + drop(outbox); + drop(_storage); + let delivery = if outcome == omachat_store::AttemptOutcome::Acknowledged { + "stored" + } else if state == Some(omachat_store::OutboxState::Failed) { + "failed" + } else { + "queued" + }; + self.publish_topic_event( + omachat_proto::ipc::Topic::Delivery, + serde_json::json!({"id": id, "delivery": delivery}), + ); + state } fn identity(&self) -> Result>, CoreError> { @@ -2620,9 +2687,20 @@ impl DaemonCore { } async fn panic_erase(&self, confirmation: &str) -> Result { - if confirmation != "ERASE" { - return Err(CoreError::ConfirmationRequired); - } + self.inner + .confirmations + .redeem( + &crate::confirmation::ConfirmationAction::PanicErase, + confirmation, + unix_time()?, + ) + .map_err(|error| match error { + crate::confirmation::ConfirmationError::Expired => CoreError::ConfirmationExpired, + crate::confirmation::ConfirmationError::Missing + | crate::confirmation::ConfirmationError::Mismatch => { + CoreError::ConfirmationRequired + } + })?; if !self.inner.panic.begin() { return Err(CoreError::Panicked); } @@ -2718,6 +2796,11 @@ impl DaemonCore { identity.take(); account.take(); } + self.inner + .chat_history + .lock() + .expect("chat history mutex poisoned") + .clear(); let erase_result = self .inner .store @@ -2820,6 +2903,27 @@ impl DaemonCore { } fn publish_topic_event(&self, topic: omachat_proto::ipc::Topic, payload: serde_json::Value) { + let mut history = self + .inner + .chat_history + .lock() + .expect("chat history mutex poisoned"); + if self.ensure_active().is_err() { + return; + } + if matches!( + topic, + omachat_proto::ipc::Topic::Messages | omachat_proto::ipc::Topic::Delivery + ) && history + .update( + &self.inner.store, + payload.clone(), + unix_time().unwrap_or_default(), + ) + .is_err() + { + eprintln!("chat history persistence failed"); + } self.inner.events.publish(Event { version: VERSION, sequence: self.inner.sequence.fetch_add(1, Ordering::Relaxed), @@ -2982,17 +3086,11 @@ impl DaemonCore { } fn publish_message_event(&self, id: &str, conversation: &str, text: &str, delivery: &str) { - self.inner.events.publish(Event { - version: VERSION, - sequence: self.inner.sequence.fetch_add(1, Ordering::Relaxed), - topic: omachat_proto::ipc::Topic::Messages, - payload: serde_json::json!({ - "id": id, - "conversation": conversation, - "text": text, - "delivery": delivery, - }), - }); + self.publish_topic_event(omachat_proto::ipc::Topic::Messages, serde_json::json!({ + "id": id, "conversation": conversation, "text": text, "delivery": delivery, + "outgoing": delivery != "received", + "sender": if delivery == "received" { conversation.strip_prefix("dm:").unwrap_or("peer") } else { "you" }, + })); } } @@ -3049,7 +3147,7 @@ mod relay_list_publication_lifecycle_tests { .await .expect("open configured core"); - core.panic_erase("ERASE") + core.panic_erase(&super::minted_panic_token(&core)) .await .expect("panic erasure completes"); @@ -3098,6 +3196,32 @@ fn panic_unavailable() -> ResponseOutcome { } } +fn confirmation_issue_value(issued: &crate::confirmation::IssuedConfirmation) -> serde_json::Value { + serde_json::json!({ + "token_path": issued.token_path.display().to_string(), + "expires_at": issued.expires_at, + "ttl_seconds": crate::confirmation::CONFIRMATION_TTL_SECONDS, + }) +} + +/// Mint a real panic-confirmation token for tests: destructive commands are +/// no longer authorized by a constant string. +#[cfg(test)] +fn minted_panic_token(core: &DaemonCore) -> String { + let issued = core + .inner + .confirmations + .issue( + crate::confirmation::ConfirmationAction::PanicErase, + unix_time().expect("clock"), + ) + .expect("issue panic token"); + std::fs::read_to_string(issued.token_path) + .expect("token file") + .trim() + .to_owned() +} + fn unix_time() -> Result { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -3313,7 +3437,8 @@ mod tests { let waiting_core = core.clone(); let waiter = tokio::spawn(async move { waiting_core.wait_for_panic_terminal().await }); let panic_core = core.clone(); - let panic = tokio::spawn(async move { panic_core.panic_erase("ERASE").await }); + let panic_token = super::minted_panic_token(&core); + let panic = tokio::spawn(async move { panic_core.panic_erase(&panic_token).await }); tokio::time::timeout(Duration::from_secs(1), async { while core.panic_state() != PanicState::Erasing { @@ -3370,7 +3495,8 @@ mod tests { core.prepare_for_shutdown().await; assert_eq!(core.panic_state(), PanicState::Stopping); - assert!(core.panic_erase("ERASE").await.is_err()); + let token = super::minted_panic_token(&core); + assert!(core.panic_erase(&token).await.is_err()); assert!(temporary.path().exists(), "late panic did not erase state"); } diff --git a/crates/omachatd/src/core_error.rs b/crates/omachatd/src/core_error.rs index 8e2e7a0..9829bff 100644 --- a/crates/omachatd/src/core_error.rs +++ b/crates/omachatd/src/core_error.rs @@ -58,6 +58,7 @@ pub enum CoreError { Random, Subscription, ConfirmationRequired, + ConfirmationExpired, PanicErase, Panicked, RestartRequired, @@ -76,6 +77,7 @@ impl CoreError { | Self::InvalidPublicKey | Self::InvalidMessage => ErrorCode::InvalidRequest, Self::ConfirmationRequired + | Self::ConfirmationExpired | Self::RegistryClaimConfirmationRequired | Self::RegistryHandleConflict | Self::RegistryBindingChanged => ErrorCode::Conflict, @@ -210,9 +212,9 @@ impl fmt::Display for CoreError { } Self::RegistryClaimPreflightUnusable => formatter .write_str("registry preflight did not return usable current account state"), - Self::RegistryClaimConfirmationRequired => { - formatter.write_str("registry handle claim requires exact handle confirmation") - } + Self::RegistryClaimConfirmationRequired => formatter.write_str( + "registry handle claim requires a fresh confirmation token; request one with request-registry-claim-confirmation", + ), Self::RegistryHandleConflict => formatter .write_str("requested handle conflicts with local or authoritative account state"), Self::RegistryBindingChanged => { @@ -236,9 +238,12 @@ impl fmt::Display for CoreError { Self::Clock => formatter.write_str("system clock is before the Unix epoch"), Self::Random => formatter.write_str("secure random generation failed"), Self::Subscription => formatter.write_str("Nostr subscription refresh failed"), - Self::ConfirmationRequired => { - formatter.write_str("panic erase requires exact confirmation ERASE") - } + Self::ConfirmationRequired => formatter.write_str( + "panic erase requires a fresh confirmation token; request one with request-panic-confirmation", + ), + Self::ConfirmationExpired => formatter.write_str( + "confirmation token expired or was consumed; request a new one", + ), Self::PanicErase => { formatter.write_str("panic erase cannot run in this runtime context") } diff --git a/crates/omachatd/src/ipc_server.rs b/crates/omachatd/src/ipc_server.rs index 4fde46e..b1bca80 100644 --- a/crates/omachatd/src/ipc_server.rs +++ b/crates/omachatd/src/ipc_server.rs @@ -8,7 +8,7 @@ use std::{ fmt, fs, fs::{File, OpenOptions}, future::Future, - os::unix::fs::{FileTypeExt, OpenOptionsExt, PermissionsExt}, + os::unix::fs::{DirBuilderExt, FileTypeExt, OpenOptionsExt, PermissionsExt}, path::{Path, PathBuf}, pin::Pin, sync::{Arc, Mutex}, @@ -79,6 +79,53 @@ pub struct IpcServer { events: EventHub, } +/// Publish the listening socket at its final path only once it is already +/// private. `UnixListener::bind` honours the process umask, so binding +/// directly and chmodding afterwards leaves a window in which another uid +/// can connect (finding #7) whenever the umask is permissive — a manual +/// launch without the packaged unit's `UMask=0077`, for instance. +/// +/// Overriding the umask around `bind` would close that window but is not +/// safe here: umask is process-wide, so it would also strip bits from files +/// and directories created concurrently by other threads. Instead the +/// socket is bound inside a freshly created 0700 staging directory, where +/// no other uid can reach it, tightened to 0600 there, and then renamed +/// into place. `rename` is atomic and preserves the inode and its mode, so +/// the final path never exists in a world-accessible state and clients +/// connect to the same listening socket through it. +fn bind_private_socket(socket_path: &Path) -> Result { + let parent = socket_path.parent().unwrap_or_else(|| Path::new(".")); + let file_name = socket_path + .file_name() + .ok_or(ServerError::OccupiedPath)? + .to_string_lossy() + .into_owned(); + let mut nonce = [0_u8; 16]; + getrandom::fill(&mut nonce) + .map_err(|error| ServerError::Io(std::io::Error::other(error.to_string())))?; + let staging_directory = parent.join(format!(".{file_name}.{}", hex::encode(nonce))); + fs::DirBuilder::new() + .mode(0o700) + .create(&staging_directory) + .map_err(ServerError::Io)?; + // The requested 0700 is masked, never widened, by the process umask; + // this restores the owner bits an exotic umask could have removed while + // the directory was still empty. + fs::set_permissions(&staging_directory, fs::Permissions::from_mode(0o700)) + .map_err(ServerError::Io)?; + let staged_socket = staging_directory.join("socket"); + let bound = UnixListener::bind(&staged_socket); + let published = bound.and_then(|listener| { + fs::set_permissions(&staged_socket, fs::Permissions::from_mode(0o600)) + .and_then(|()| fs::rename(&staged_socket, socket_path)) + .map(|()| listener) + }); + // The staging directory is transient on every path, including failure. + let _ = fs::remove_file(&staged_socket); + let _ = fs::remove_dir(&staging_directory); + published.map_err(ServerError::Io) +} + impl IpcServer { pub fn bind( socket_path: impl AsRef, @@ -108,9 +155,7 @@ impl IpcServer { } fs::remove_file(&socket_path).map_err(ServerError::Io)?; } - let listener = UnixListener::bind(&socket_path).map_err(ServerError::Io)?; - fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600)) - .map_err(ServerError::Io)?; + let listener = bind_private_socket(&socket_path)?; Ok(Self { listener, socket_path, @@ -125,6 +170,7 @@ impl IpcServer { let mut clients = JoinSet::new(); let mut terminal_error = None; let (client_shutdown_sender, client_shutdown) = watch::channel(false); + let daemon_euid = rustix::process::geteuid().as_raw(); loop { if *shutdown.borrow() { break; @@ -144,6 +190,12 @@ impl IpcServer { break; } }; + match stream.peer_cred() { + Ok(credentials) if peer_permitted(credentials.uid(), daemon_euid) => {} + // Fail closed: a foreign or unreadable peer gets no + // protocol bytes at all, not even an error frame. + Ok(_) | Err(_) => continue, + } let handler = Arc::clone(&self.handler); let events = self.events.clone(); let client_shutdown = client_shutdown.clone(); @@ -200,8 +252,8 @@ async fn serve_client( let mut decoder = RequestDecoder::default(); let mut read_buffer = [0_u8; CLIENT_READ_CHUNK]; let mut negotiated = false; - let mut subscribed = false; - let mut event_receiver = events.subscribe(); + let mut topics = Vec::new(); + let mut event_receiver: Option> = None; loop { if *shutdown.borrow() { @@ -245,8 +297,9 @@ async fn serve_client( supported: VERSION, }, ), - Command::Subscribe { .. } => { - subscribed = true; + Command::Subscribe { topics: requested } => { + topics = requested.clone(); + event_receiver = Some(events.subscribe()); handler.handle(request).await } _ => handler.handle(request).await, @@ -261,8 +314,9 @@ async fn serve_client( } } } - event = event_receiver.recv(), if subscribed => { + event = async { event_receiver.as_mut().expect("subscribed").recv().await }, if event_receiver.is_some() => { let Some(event) = event else { return Ok(()) }; + if !topics.contains(&event.topic) { continue; } let encoded = encode_line(&event).map_err(ServerError::Protocol)?; tokio::select! { biased; @@ -280,6 +334,15 @@ async fn serve_client( } } +/// SO_PEERCRED authorization: only the daemon's own effective uid may speak +/// the protocol. The uid in `UCred` is fixed by the kernel at connect() +/// time and cannot be spoofed by the client. The pid is deliberately not +/// consulted (pid reuse races). This is defense in depth, not a hard +/// boundary: a same-uid process can ptrace the daemon — see SECURITY.md. +fn peer_permitted(peer_uid: u32, daemon_euid: u32) -> bool { + peer_uid == daemon_euid +} + fn protocol_error(code: ErrorCode, error: &impl fmt::Display) -> ResponseOutcome { ResponseOutcome::Error { error: ErrorBody { @@ -317,3 +380,42 @@ impl Error for ServerError { } } } + +#[cfg(test)] +mod tests { + use super::{bind_private_socket, peer_permitted}; + use std::os::unix::fs::PermissionsExt; + use tempfile::tempdir; + + #[tokio::test] + async fn the_published_socket_is_private_and_leaves_no_staging_directory() { + let temporary = tempdir().expect("temporary directory"); + let socket = temporary.path().join("omachat.sock"); + let listener = bind_private_socket(&socket).expect("bind private socket"); + assert_eq!( + std::fs::metadata(&socket) + .expect("socket metadata") + .permissions() + .mode() + & 0o777, + 0o600, + "the socket is 0600 the moment it appears at its final path" + ); + assert!( + !temporary.path().join(".omachat.sock.staging").exists(), + "the staging directory is removed after publication" + ); + drop(listener); + } + + #[test] + fn only_the_daemon_uid_is_permitted() { + assert!(peer_permitted(1000, 1000)); + assert!(!peer_permitted(1001, 1000)); + // Root is not exempted: a root peer can bypass any socket check + // through other means, so accepting it here would only widen the + // daemon's accepted-input surface without adding capability. + assert!(!peer_permitted(0, 1000)); + assert!(peer_permitted(0, 0)); + } +} diff --git a/crates/omachatd/src/lib.rs b/crates/omachatd/src/lib.rs index 340718d..ce4518b 100644 --- a/crates/omachatd/src/lib.rs +++ b/crates/omachatd/src/lib.rs @@ -1,7 +1,9 @@ //! Headless daemon IPC service primitives. mod agent_lifecycle_store; +mod chat_history; mod config; +mod confirmation; mod core; mod core_error; mod dm_delivery_service; @@ -34,6 +36,10 @@ pub use config::{ RelayListPublicationConfig, RelayListPublicationRelayConfig, RoomsConfig, StorageProviderConfig, }; +pub use confirmation::{ + CONFIRMATION_TTL_SECONDS, ConfirmationAction, ConfirmationError, DestructiveConfirmations, + IssuedConfirmation, +}; pub use core::{ DaemonCore, PanicState, RegistryClaimEvidence, RegistryClaimResult, RegistryClaimStatus, }; diff --git a/crates/omachatd/src/main.rs b/crates/omachatd/src/main.rs index 22186c9..11d7291 100644 --- a/crates/omachatd/src/main.rs +++ b/crates/omachatd/src/main.rs @@ -194,8 +194,13 @@ impl Options { .map(PathBuf::from) .ok_or("XDG_RUNTIME_DIR is unset")? .join("omachat/omachat.sock"); + let config = env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))) + .map(|root| root.join("omachat/config.json")) + .filter(|path| path.exists()); let mut options = Self { - config: None, + config, state, socket, anchors: None, diff --git a/crates/omachatd/tests/confirmation_ipc.rs b/crates/omachatd/tests/confirmation_ipc.rs new file mode 100644 index 0000000..25d5a4e --- /dev/null +++ b/crates/omachatd/tests/confirmation_ipc.rs @@ -0,0 +1,146 @@ +use omachat_proto::ipc::{Command, Request, ResponseOutcome, VERSION}; +use omachatd::{DaemonConfig, DaemonCore, EventHub, RequestHandler, StorageProviderConfig}; +use std::os::unix::fs::PermissionsExt; +use tempfile::tempdir; + +async fn open_core(state: &std::path::Path) -> DaemonCore { + DaemonCore::open( + state, + DaemonConfig { + storage_provider: StorageProviderConfig::File, + ..DaemonConfig::default() + }, + EventHub::default(), + ) + .await + .expect("open core") +} + +async fn ok_result(core: &DaemonCore, id: &str, command: Command) -> serde_json::Value { + let outcome = core + .handle(Request { + version: VERSION, + id: id.into(), + command, + }) + .await; + let ResponseOutcome::Ok { result } = outcome else { + panic!("expected ok outcome, got {outcome:?}"); + }; + result +} + +#[tokio::test] +async fn panic_confirmation_request_mints_a_private_token_file() { + let temporary = tempdir().expect("temporary directory"); + let core = open_core(temporary.path()).await; + let result = ok_result(&core, "token", Command::RequestPanicConfirmation).await; + let token_path = result + .get("token_path") + .and_then(serde_json::Value::as_str) + .expect("token_path in result"); + assert!(token_path.starts_with(temporary.path().to_str().expect("utf8 path"))); + let mode = std::fs::metadata(token_path) + .expect("token metadata") + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + assert_eq!( + std::fs::read_to_string(token_path) + .expect("token file") + .len(), + 64 + ); + assert!( + result + .get("expires_at") + .and_then(serde_json::Value::as_u64) + .is_some() + ); + assert_eq!( + result + .get("ttl_seconds") + .and_then(serde_json::Value::as_u64), + Some(120) + ); +} + +#[tokio::test] +async fn claim_confirmation_request_validates_the_handle() { + let temporary = tempdir().expect("temporary directory"); + let core = open_core(temporary.path()).await; + let outcome = core + .handle(Request { + version: VERSION, + id: "bad".into(), + command: Command::RequestRegistryClaimConfirmation { + handle: "NOT A HANDLE".into(), + }, + }) + .await; + assert!( + matches!(outcome, ResponseOutcome::Error { .. }), + "invalid handles must not mint tokens" + ); + let result = ok_result( + &core, + "good", + Command::RequestRegistryClaimConfirmation { + handle: "tom".into(), + }, + ) + .await; + assert!( + result + .get("token_path") + .and_then(serde_json::Value::as_str) + .is_some() + ); +} + +#[tokio::test] +async fn the_legacy_erase_constant_no_longer_authorizes_panic() { + let temporary = tempdir().expect("temporary directory"); + let core = open_core(temporary.path()).await; + let outcome = core + .handle(Request { + version: VERSION, + id: "legacy".into(), + command: Command::Panic { + confirmation: "ERASE".into(), + }, + }) + .await; + assert!(matches!(outcome, ResponseOutcome::Error { .. })); + assert!( + !core.is_panicked(), + "a rejected confirmation must not erase" + ); +} + +#[tokio::test] +async fn a_minted_token_authorizes_panic_exactly_once() { + let temporary = tempdir().expect("temporary directory"); + let core = open_core(temporary.path()).await; + let result = ok_result(&core, "token", Command::RequestPanicConfirmation).await; + let token_path = result + .get("token_path") + .and_then(serde_json::Value::as_str) + .expect("token_path") + .to_owned(); + let token = std::fs::read_to_string(&token_path) + .expect("token file") + .trim() + .to_owned(); + let outcome = core + .handle(Request { + version: VERSION, + id: "commit".into(), + command: Command::Panic { + confirmation: token, + }, + }) + .await; + assert!(matches!(outcome, ResponseOutcome::Ok { .. }), "{outcome:?}"); + assert!(core.is_panicked()); +} diff --git a/crates/omachatd/tests/core.rs b/crates/omachatd/tests/core.rs index 4a58780..9e47229 100644 --- a/crates/omachatd/tests/core.rs +++ b/crates/omachatd/tests/core.rs @@ -10,6 +10,30 @@ use tempfile::tempdir; use tokio::net::{TcpListener, TcpStream}; use tokio_tungstenite::{WebSocketStream, accept_async, tungstenite::Message}; +/// Destructive commands require a daemon-minted single-use token; mint one +/// over the same IPC surface the client uses. +async fn minted_panic_token(core: &DaemonCore) -> String { + let outcome = core + .handle(Request { + version: VERSION, + id: "panic-token".into(), + command: Command::RequestPanicConfirmation, + }) + .await; + let ResponseOutcome::Ok { result } = outcome else { + panic!("panic token issuance failed: {outcome:?}"); + }; + let path = result + .get("token_path") + .and_then(serde_json::Value::as_str) + .expect("token_path") + .to_owned(); + std::fs::read_to_string(path) + .expect("token file") + .trim() + .to_owned() +} + async fn command(core: &DaemonCore, command: Command) -> serde_json::Value { match core .handle(Request { @@ -96,6 +120,15 @@ async fn identity_outbox_and_commands_survive_restart() { ); assert_eq!(second_status["account"]["registry_state"], "unconfigured"); assert_eq!(second_status["outbox_pending"], 1); + let snapshot = command( + &reopened, + Command::Subscribe { + topics: vec![omachat_proto::ipc::Topic::Messages], + }, + ) + .await; + assert_eq!(snapshot["messages"][0]["text"], "private restart message"); + assert_eq!(snapshot["messages"][0]["delivery"], "queued"); let backing = fs::read(temporary.path().join("records/nostr-outbox-v1")).expect("sealed outbox backing"); @@ -348,10 +381,11 @@ async fn panic_requires_confirmation_erases_state_and_rejects_more_work() { .await; assert!(matches!(denied, ResponseOutcome::Error { .. })); assert_eq!(core.panic_state(), PanicState::Active); + let token = minted_panic_token(&core).await; command( &core, Command::Panic { - confirmation: "ERASE".into(), + confirmation: token, }, ) .await; @@ -388,12 +422,13 @@ async fn panic_cleanup_failure_is_terminal_and_never_reenables_the_daemon() { .expect("open core"); fs::remove_file(temporary.path().join("master.key")).expect("inject key cleanup failure"); + let token = minted_panic_token(&core).await; let failed = core .handle(Request { version: VERSION, id: "panic-failure".into(), command: Command::Panic { - confirmation: "ERASE".into(), + confirmation: token, }, }) .await; @@ -472,13 +507,14 @@ async fn panic_cancels_a_slow_publish_before_erasing_and_emits_no_local_message( .expect("relay event signal"); let panic_core = core.clone(); + let panic_token = minted_panic_token(&core).await; let panic = tokio::spawn(async move { panic_core .handle(Request { version: VERSION, id: "panic-during-send".into(), command: Command::Panic { - confirmation: "ERASE".into(), + confirmation: panic_token, }, }) .await diff --git a/crates/omachatd/tests/dm_inbox_enable.rs b/crates/omachatd/tests/dm_inbox_enable.rs index c50a18f..e412383 100644 --- a/crates/omachatd/tests/dm_inbox_enable.rs +++ b/crates/omachatd/tests/dm_inbox_enable.rs @@ -10,6 +10,31 @@ use omachat_proto::ipc::{Command, Request, ResponseOutcome, Topic, VERSION}; use omachatd::{DaemonConfig, DaemonCore, EventHub, RequestHandler, StorageProviderConfig}; use serde_json::{Value, json}; use tempfile::tempdir; + +/// Destructive commands require a daemon-minted single-use token; mint one +/// over the same IPC surface the client uses. +async fn minted_panic_token(core: &DaemonCore) -> String { + let outcome = core + .handle(Request { + version: VERSION, + id: "panic-token".into(), + command: Command::RequestPanicConfirmation, + }) + .await; + let ResponseOutcome::Ok { result } = outcome else { + panic!("panic token issuance failed: {outcome:?}"); + }; + let path = result + .get("token_path") + .and_then(serde_json::Value::as_str) + .expect("token_path") + .to_owned(); + std::fs::read_to_string(path) + .expect("token file") + .trim() + .to_owned() +} + use tokio::{ net::{TcpListener, TcpStream}, time::timeout, @@ -101,10 +126,11 @@ async fn configured_private_inbox_reaches_ipc_and_quiesces_before_panic_erasure( ); assert_eq!(event.payload["delivery"], "received"); + let token = minted_panic_token(&core).await; let erased = command( &core, Command::Panic { - confirmation: "ERASE".to_owned(), + confirmation: token, }, ) .await; diff --git a/crates/omachatd/tests/geo_relay_runtime.rs b/crates/omachatd/tests/geo_relay_runtime.rs index 80b7060..a300457 100644 --- a/crates/omachatd/tests/geo_relay_runtime.rs +++ b/crates/omachatd/tests/geo_relay_runtime.rs @@ -244,14 +244,13 @@ async fn runtime_routes_cells_independently_and_leave_removes_the_pool() { ResponseOutcome::Error { .. } )); // Panic must stop the new pools before destroying keys too. + let issued = command(&core, Command::RequestPanicConfirmation).await; + let ResponseOutcome::Ok { result } = issued else { + panic!("confirmation refused") + }; + let confirmation = std::fs::read_to_string(result["token_path"].as_str().unwrap()).unwrap(); assert!(matches!( - command( - &core, - Command::Panic { - confirmation: "ERASE".into() - } - ) - .await, + command(&core, Command::Panic { confirmation }).await, ResponseOutcome::Ok { .. } )); service.shutdown().await; diff --git a/crates/omachatd/tests/ipc_server.rs b/crates/omachatd/tests/ipc_server.rs index a1b38bc..0a39f0a 100644 --- a/crates/omachatd/tests/ipc_server.rs +++ b/crates/omachatd/tests/ipc_server.rs @@ -123,8 +123,8 @@ async fn socket_is_private_and_hello_status_work() { version: VERSION, id: "hello".into(), command: Command::Hello { - minimum_version: 1, - maximum_version: 1, + minimum_version: VERSION, + maximum_version: VERSION, }, }, Request { @@ -368,13 +368,37 @@ async fn panic_response_during_terminal_shutdown( }, ) .await; + // Destructive commands need a daemon-minted single-use token, obtained + // over the same connection before the panic request itself. + let token_response = write_request( + &mut writer, + &mut reader, + Request { + version: VERSION, + id: "token".into(), + command: Command::RequestPanicConfirmation, + }, + ) + .await; + let ResponseOutcome::Ok { result } = token_response.outcome else { + panic!("token issuance failed"); + }; + let token = std::fs::read_to_string( + result + .get("token_path") + .and_then(serde_json::Value::as_str) + .expect("token_path"), + ) + .expect("token file") + .trim() + .to_owned(); writer .write_all( &encode_line(&Request { version: VERSION, id: "panic".into(), command: Command::Panic { - confirmation: "ERASE".into(), + confirmation: token, }, }) .expect("encode panic request"), diff --git a/crates/omachatd/tests/ipc_socket_mode.rs b/crates/omachatd/tests/ipc_socket_mode.rs new file mode 100644 index 0000000..e143650 --- /dev/null +++ b/crates/omachatd/tests/ipc_socket_mode.rs @@ -0,0 +1,112 @@ +//! Finding #7: the socket must never be reachable at its final path in a +//! world-accessible state. A permissive process umask simulates a manual +//! (non-systemd) launch without the packaged unit's UMask=0077, which is +//! exactly the case the old chmod-after-bind sequence left exposed. + +use omachat_proto::ipc::{ + Command, Request, Response, ResponseOutcome, VERSION, encode_line, negotiate, +}; +use omachatd::{EventHub, IpcServer, RequestHandler}; +use rustix::fs::Mode; +use serde_json::{json, to_value}; +use std::{future::Future, os::unix::fs::PermissionsExt, pin::Pin, time::Duration}; +use tempfile::tempdir; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + net::UnixStream, + sync::watch, +}; + +struct Handler; + +impl RequestHandler for Handler { + fn handle( + &self, + request: Request, + ) -> Pin + Send + '_>> { + Box::pin(async move { + match request.command { + Command::Hello { + minimum_version, + maximum_version, + } => match negotiate(minimum_version, maximum_version) { + Ok(result) => ResponseOutcome::Ok { + result: to_value(result).expect("hello result"), + }, + Err(error) => ResponseOutcome::Error { + error: omachat_proto::ipc::ErrorBody { + code: omachat_proto::ipc::ErrorCode::VersionMismatch, + message: error.to_string(), + }, + }, + }, + _ => ResponseOutcome::Ok { result: json!({}) }, + } + }) + } +} + +#[tokio::test] +async fn socket_is_published_private_even_under_a_permissive_umask() { + // umask is process-wide; this integration-test binary holds a single + // test, so nothing else in the process depends on it. The daemon must + // not need this to be restrictive. + let previous = rustix::process::umask(Mode::empty()); + let temporary = tempdir().expect("temporary directory"); + let socket = temporary.path().join("omachat.sock"); + let server = IpcServer::bind(&socket, Handler, EventHub::default()).expect("bind IPC server"); + let mode = std::fs::metadata(&socket) + .expect("socket metadata") + .permissions() + .mode(); + assert_eq!( + mode & 0o777, + 0o600, + "socket must already be private when it appears at its final path" + ); + assert!( + !temporary.path().join(".omachat.sock.staging").exists(), + "staging directory must not be left behind" + ); + // The published path must still be the live listening socket: rename + // keeps the inode, so clients connect through it normally. + let (shutdown_sender, shutdown_receiver) = watch::channel(false); + let task = tokio::spawn(server.run(shutdown_receiver)); + let stream = UnixStream::connect(&socket).await.expect("connect"); + let (reader, mut writer) = stream.into_split(); + let mut reader = BufReader::new(reader); + writer + .write_all( + &encode_line(&Request { + version: VERSION, + id: "hello".into(), + command: Command::Hello { + minimum_version: VERSION, + maximum_version: VERSION, + }, + }) + .expect("encode hello"), + ) + .await + .expect("write hello"); + let mut line = String::new(); + tokio::time::timeout(Duration::from_secs(2), reader.read_line(&mut line)) + .await + .expect("hello response timeout") + .expect("read hello response"); + let response: Response = serde_json::from_str(&line).expect("hello response"); + assert!(matches!(response.outcome, ResponseOutcome::Ok { .. })); + + shutdown_sender.send(true).expect("shutdown"); + tokio::time::timeout(Duration::from_secs(2), task) + .await + .expect("server shutdown") + .expect("server task") + .expect("server result"); + let observed = rustix::process::umask(previous); + assert_eq!( + observed, + Mode::empty(), + "binding must not change the process umask" + ); +} diff --git a/crates/omachatd/tests/principal_registry_claim.rs b/crates/omachatd/tests/principal_registry_claim.rs index e364a42..f7ca260 100644 --- a/crates/omachatd/tests/principal_registry_claim.rs +++ b/crates/omachatd/tests/principal_registry_claim.rs @@ -16,6 +16,32 @@ use omachatd::{ use tempfile::tempdir; use tokio::{net::TcpListener, sync::oneshot}; +/// Registry claims require a daemon-minted single-use token bound to the +/// exact handle; mint one over the same IPC surface the client uses. +async fn minted_claim_token(core: &DaemonCore, handle: &str) -> String { + let outcome = core + .handle(Request { + version: VERSION, + id: "claim-token".into(), + command: Command::RequestRegistryClaimConfirmation { + handle: handle.to_owned(), + }, + }) + .await; + let ResponseOutcome::Ok { result } = outcome else { + panic!("claim token issuance failed: {outcome:?}"); + }; + let path = result + .get("token_path") + .and_then(serde_json::Value::as_str) + .expect("token_path") + .to_owned(); + std::fs::read_to_string(path) + .expect("token file") + .trim() + .to_owned() +} + fn now() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -24,13 +50,14 @@ fn now() -> u64 { } async fn claim(core: &DaemonCore) -> serde_json::Value { + let confirmation = minted_claim_token(core, "alice").await; match core .handle(Request { version: VERSION, id: "principal-registry-claim".into(), command: Command::ClaimRegistryHandle { handle: "alice".into(), - confirmation: "alice".into(), + confirmation, }, }) .await diff --git a/crates/omachatd/tests/profile_publication_ipc.rs b/crates/omachatd/tests/profile_publication_ipc.rs index 7588927..594fc98 100644 --- a/crates/omachatd/tests/profile_publication_ipc.rs +++ b/crates/omachatd/tests/profile_publication_ipc.rs @@ -11,6 +11,30 @@ use tokio::net::TcpListener; use tokio::sync::oneshot; use tokio_tungstenite::{accept_async, tungstenite::Message}; +/// Destructive commands require a daemon-minted single-use token; mint one +/// over the same IPC surface the client uses. +async fn minted_panic_token(core: &DaemonCore) -> String { + let outcome = core + .handle(Request { + version: VERSION, + id: "panic-token".into(), + command: Command::RequestPanicConfirmation, + }) + .await; + let ResponseOutcome::Ok { result } = outcome else { + panic!("panic token issuance failed: {outcome:?}"); + }; + let path = result + .get("token_path") + .and_then(serde_json::Value::as_str) + .expect("token_path") + .to_owned(); + std::fs::read_to_string(path) + .expect("token file") + .trim() + .to_owned() +} + #[tokio::test] async fn ipc_publishes_a_device_principal_profile() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -171,13 +195,14 @@ async fn panic_erasure_cancels_profile_publication_before_erasing_keys() { }) }; publication_started.await.unwrap(); + let token = minted_panic_token(&core).await; let panic = tokio::time::timeout( std::time::Duration::from_secs(2), core.handle(Request { version: VERSION, id: "panic-profile".into(), command: Command::Panic { - confirmation: "ERASE".into(), + confirmation: token, }, }), ) diff --git a/crates/omachatd/tests/registry_claim.rs b/crates/omachatd/tests/registry_claim.rs index 5c2fd6c..f84437d 100644 --- a/crates/omachatd/tests/registry_claim.rs +++ b/crates/omachatd/tests/registry_claim.rs @@ -14,6 +14,33 @@ use omachatd::{ RequestHandler, StorageProviderConfig, }; use tempfile::tempdir; + +/// Registry claims require a daemon-minted single-use token bound to the +/// exact handle; mint one over the same IPC surface the client uses. +async fn minted_claim_token(core: &DaemonCore, handle: &str) -> String { + let outcome = core + .handle(Request { + version: VERSION, + id: "claim-token".into(), + command: Command::RequestRegistryClaimConfirmation { + handle: handle.to_owned(), + }, + }) + .await; + let ResponseOutcome::Ok { result } = outcome else { + panic!("claim token issuance failed: {outcome:?}"); + }; + let path = result + .get("token_path") + .and_then(serde_json::Value::as_str) + .expect("token_path") + .to_owned(); + std::fs::read_to_string(path) + .expect("token file") + .trim() + .to_owned() +} + use tokio::{net::TcpListener, sync::oneshot}; fn now() -> u64 { @@ -94,13 +121,14 @@ async fn pending_claim_replays_after_restart_and_clears_after_durable_receipt() ) .await .expect("daemon core"); + let confirmation = minted_claim_token(&core, "alice").await; let result = core .handle(Request { version: VERSION, id: "claim-handle".into(), command: Command::ClaimRegistryHandle { handle: "alice".into(), - confirmation: "alice".into(), + confirmation, }, }) .await; @@ -167,13 +195,16 @@ async fn offline_preflight_never_creates_a_new_claim_intent() { ) .await .expect("daemon core"); + // A token minted for another handle must not confirm this claim, and the + // legacy handle echo is no longer a confirmation at all. + let foreign_token = minted_claim_token(&core, "bob").await; let rejected = core .handle(Request { version: VERSION, id: "wrong-confirmation".into(), command: Command::ClaimRegistryHandle { handle: "alice".into(), - confirmation: "bob".into(), + confirmation: foreign_token, }, }) .await; diff --git a/deny.toml b/deny.toml index 666d0ed..2ed39b0 100644 --- a/deny.toml +++ b/deny.toml @@ -20,6 +20,14 @@ allow = [ "Unicode-3.0", ] +# The pinned ratatui stack reaches foldhash through hashbrown, which every +# ratatui-core layout and cache path depends on unconditionally. Zlib is +# permissive and carries no source obligation, but it stays scoped to this one +# crate rather than widening the workspace allow list. +[[licenses.exceptions]] +name = "foldhash" +allow = ["Zlib"] + [sources] unknown-registry = "deny" unknown-git = "deny" diff --git a/docs/development.md b/docs/development.md index 6d8158b..bf93976 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,6 +1,6 @@ # Development contract -OmaChat 0.0.1 is a Rust workspace containing ten crates. The toolchain is +OmaChat 0.0.1 is a Rust workspace containing eleven crates. The toolchain is locked to Rust 1.98.0 in `rust-toolchain.toml`; the workspace manifest declares Rust 1.98 as its minimum supported Rust version (MSRV). Changing either value requires a pull request that updates both together and demonstrates the full @@ -61,3 +61,11 @@ The dependency policy permits 0BSD, Apache-2.0, MIT, Unicode-3.0, ISC, and the two- and three-clause BSD licenses. The BSD/ISC additions cover the pinned BlueR and Rustls probe stacks; they do not weaken the Android clean-room rule or allow copyleft source into OmaChat. + +Release-binary terminal and local-relay smoke checks: + +```sh +python3 scripts/test-tui-pty.py +# Requires the localhost Grain test relay on port 18181: +python3 scripts/test-local-dm.py +``` diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 0272df3..0751f22 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -34,7 +34,7 @@ deployed registry returns a receipt that the daemon verifies and caches. | Area | Implemented in this branch | Still requires external evidence or work | |---|---|---| | G0 prerequisites | Frozen pins, fixtures, georelay policy, BlueR/proxy/keyring probes | Named-adapter dual-role capture; Omarchy keyring/service lifecycle matrix; live system-Tor/Arti observations | -| G1 Nostr product | Sealed providers/records and explicit migration; four independent identity roots; strict event/envelope/relay/pool/georelay/mailbox/outbox code; versioned IPC; CLI/TUI; daemon relay actor, subscriptions, publish/receive, reload and restart tests | Pinned iOS/Android live chat/presence/private delivery; real relay/proxy gate; target-host keyring lifecycle; TUI remains line-input rather than a ratatui/crossterm raw-event loop | +| G1 Nostr product | Sealed providers/records and explicit migration; four independent identity roots; strict event/envelope/relay/pool/georelay/mailbox/outbox code; versioned IPC; CLI/TUI; daemon relay actor, subscriptions, publish/receive, reload and restart tests | Pinned iOS/Android live chat/presence/private delivery; real relay/proxy gate; target-host keyring lifecycle; ratatui TUI now consumes live events and bounded sealed snapshots, supports scroll/compose and reconnect, and restores the terminal on detach/signals/panic; full product conformance remains open | | G1 account foundation | Distinct, provisionally co-resident sealed account/recovery roots; stable account/device IDs; strict candidate handles/display names; signed local device/profile binding; restart-stable daemon status that labels configured handles `local-only`; authoritative uniqueness/CAS/idempotency state with sealed restart-safe persistence; durable accepted-claim evidence; exact claim-bound, pinned-key global/per-account hash-chained receipts; verified handle/account lookup over a bounded versioned service/client adapter; rename/reuse policy explicitly deferred | Off-device recovery custody; registry deployment and daemon integration; sealed verified freshness cache; device enrollment/revocation/recovery; key-transparency evidence; no live claim | | G2 public mesh (retained, deferred) | Bounded v1/v2 packet, compression/signing, announce, fragmentation, GCS/request-sync, presence/routing/dedup/source-route logic, six-hour sealed public archive, 15-minute transient caches, bounded RSR backfill selection, production BlueR GATT/advertise/discover/read/write runtime, duplicate-link and adapter-loss state | Daemon-level mesh orchestration; real BlueZ policy/adapter qualification; pinned Swift/Android one/two-hop corpus and live tests; long-running radio convergence | | G3 private mesh (retained, deferred) | Exact captured Noise XX transcript/transport; replay/reorder/rekey state; authenticated pins; private messages/receipts/dedup; favorite/challenge/vouch controls; sealed favorites/verification/blocks; deterministic mesh/Nostr/queue route policy; transport-attempt history; ANSI QR output through packaged `qrencode` | Live Noise/QR/favorite exchange against pinned phones; daemon-level mesh session/outbox orchestration; scanned-QR input workflow | @@ -64,3 +64,21 @@ have `cargo-fuzz`/`libfuzzer-sys` installed locally. live global-uniqueness evidence, and no key-transparency claim is complete. - Workspaces, membership, permissions, channels, threads, reactions, search, files, and native notifications are future text-collaboration work. + + +## XPS development trial integration — 2026-09-08 + +The trial integrates PR #226 (IPC v2 peer credentials and destructive +confirmations) and PR #224 (ratatui renderer/input). Follow-up work adds a +bounded demultiplexing client reader, filtered subscriptions with recent-message +snapshots, sealed local UI history, live TUI delivery/unread state, navigation, +reconnect/backoff, signal/panic terminal restoration, XDG config discovery and +writable room anchors. The scheduled fuzz command now explicitly selects nightly. + +Local checks on an XPS 9320 with Omarchy 4.0.2 include Clippy, workspace tests +(with the corrected disconnect mock checked separately), rustdoc, release build, +version/packaging checks, 6,422,040-byte aggregate installed binaries, real PTY +live-message/restart/signal checks, and a two-daemon NIP-17 roundtrip through +pinned Grain v0.7.1 exposed only on localhost. These checks do not establish +mobile compatibility, boot/locked-keyring/logout behaviour, production relay +retention or a 72-hour soak. The trial uses explicit file-key storage. diff --git a/docs/installation.md b/docs/installation.md index 58e57c2..fb28c78 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -6,9 +6,14 @@ inputs for the Omarchy v4.0.1 clean-install gate. ## User service OmaChat's local IPC socket is an account-local control boundary, not an -application sandbox. Socket permissions exclude other Unix users, and the -daemon refuses a concurrent instance, but processes already running as the -same account are inside this trust boundary. Run untrusted desktop software +application sandbox. Socket permissions exclude other Unix users, the daemon +rejects any connection whose peer credentials report a different uid, and it +refuses a concurrent instance, but processes already running as the same +account are inside this trust boundary. Destructive commands (`panic`, +`claim-handle`) require a single-use confirmation token that the daemon mints +into its state directory on request and that expires after 120 seconds, so a +blind one-shot write to the socket cannot erase the account; a same-account +process that can read the state directory can still complete that exchange. Run untrusted desktop software under a separate OS identity or sandbox that cannot access the account's runtime directory. In particular, do not describe the `0600` socket as authenticating individual same-user applications. @@ -134,3 +139,32 @@ is for local testing. Neither is authorized for AUR publication yet. The optional Quattro widget lives under `packaging/omarchy-quattro`; validate it with `omarchy plugin validate` on v4.0.1 before enabling. The legacy Waybar example is separate and is not the Quattro integration. + +## Development trial controls and local history + +The daemon reads `$XDG_CONFIG_HOME/omachat/config.json` (normally +`~/.config/omachat/config.json`) when present. `--config PATH` overrides it. +Packages do not create or overwrite that file. Relay configuration changes +require a restart. The unit grants write access to both its state directory +and the separate `omachat-anchors` directory used by room rollback checks. + +The TUI subscribes to live messages, delivery, presence, conversations and +status. Tab/Shift-Tab selects a conversation; Escape switches compose/scroll +mode, `i` returns to compose, and Page Up/Down scrolls history. `/help` shows +controls. A disconnected client keeps its draft and retries with a 1–30 second +backoff. It never automatically resends a draft after an ambiguous send failure. +Ctrl-C, Ctrl-D and `/detach` leave the daemon running. SIGINT, SIGTERM and the +panic hook restore the terminal, including release builds using panic-abort. + +The daemon keeps a sealed local UI cache of at most 128 messages and 32 KiB, +with a 24-hour age limit. Expiry is enforced on load, updates and snapshots; +this is a bounded recent view, not a full-history archive. Reattaching loads +this snapshot and deduplicates live events by message ID. Retry delivery +updates come from the daemon outbox. Panic clears the cache along with the +other sealed state. This local cache does not define relay or backup retention. + +IPC v2 separates response correlation from a bounded client event queue. +Overflow or malformed/incompatible input disconnects the client; the TUI +resubscribes and obtains a fresh snapshot. Subscribe responses include +`status` and `messages`; topic filters govern streamed events. A snapshot and +its queued live tail may overlap, so clients must deduplicate by message ID. diff --git a/packaging/man/omachat-protocol.7 b/packaging/man/omachat-protocol.7 index dd4566c..54727a6 100644 --- a/packaging/man/omachat-protocol.7 +++ b/packaging/man/omachat-protocol.7 @@ -2,7 +2,7 @@ .SH NAME omachat-protocol \- OmaChat compatibility and IPC contract .SH DESCRIPTION -IPC v1 is newline-delimited JSON over a 0600 Unix socket. Clients must begin +IPC v2 is newline-delimited JSON over a 0600 Unix socket. Clients must begin with hello and a supported version range. Lines are limited to 65536 bytes; unknown fields and unsupported versions are rejected. Responses retain request IDs. @@ -11,3 +11,9 @@ Wire behavior targets the immutable upstream pins documented in docs/compatibility-profile.md. A codec passing hermetic tests is not a live interoperability claim. Courier v1 and v2, Android limitations, bridge health, and uncompleted hardware/live gates are documented there and in SECURITY.md. + +.SH SUBSCRIPTIONS +Subscribe returns a bounded recent-message snapshot and current status. +Streamed events are filtered by the requested topics and interleaved with +correlated responses. Clients must deduplicate snapshot/live overlap by message ID. +Destructive commands require a daemon-issued, single-use token, valid for 120 seconds. diff --git a/packaging/man/omachat.1 b/packaging/man/omachat.1 index 58e826d..6c72818 100644 --- a/packaging/man/omachat.1 +++ b/packaging/man/omachat.1 @@ -12,6 +12,13 @@ Commands include /join, /leave, /who, /block, /send, /panic ERASE, and /detach. Panic erasure is destructive and cannot remove copies already held by relays, peers, backups, or filesystem snapshots. +.SH CONTROLS +Tab and Shift-Tab switch conversations. Escape toggles scroll/compose mode; +i returns to compose. Page Up/Down scroll messages. /help shows controls. +On disconnect, drafts are preserved and connection retries back off to 30 seconds. +Failed sends are never automatically resubmitted by the client. +Recent messages are loaded from a bounded sealed daemon cache on reattach. +Ctrl-C, Ctrl-D, /detach, SIGINT and SIGTERM detach without stopping the daemon. .SH FILES .I $XDG_RUNTIME_DIR/omachat/omachat.sock .SH SEE ALSO diff --git a/packaging/systemd/omachatd.service b/packaging/systemd/omachatd.service index ddb7337..fd7e1e5 100644 --- a/packaging/systemd/omachatd.service +++ b/packaging/systemd/omachatd.service @@ -11,14 +11,14 @@ Restart=on-failure RestartSec=3s RuntimeDirectory=omachat RuntimeDirectoryMode=0700 -StateDirectory=omachat +StateDirectory=omachat omachat-anchors StateDirectoryMode=0700 UMask=0077 NoNewPrivileges=yes PrivateTmp=yes ProtectSystem=strict ProtectHome=read-only -ReadWritePaths=%S/omachat %t/omachat +ReadWritePaths=%S/omachat %S/omachat-anchors %t/omachat ProtectKernelTunables=yes ProtectKernelModules=yes ProtectKernelLogs=yes diff --git a/scripts/test-local-dm.py b/scripts/test-local-dm.py new file mode 100644 index 0000000..b27352d --- /dev/null +++ b/scripts/test-local-dm.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Two real release daemons exchange NIP-17 DMs through a local Grain relay.""" +import json +import os +from pathlib import Path +import socket +import subprocess +import tempfile +import time + +BIN = Path(__file__).resolve().parents[1] / "target/release" + + +def main(): + processes = [] + with tempfile.TemporaryDirectory(prefix="omachat-dm-") as temporary: + root = Path(temporary) + log = open(root / "daemons.log", "wb") + try: + sockets = [] + keys = [] + for name in ["alice", "bob"]: + config = root / f"{name}.json" + config.write_text(json.dumps({"storage_provider": "file", "dm_relays": ["ws://127.0.0.1:18181"]})) + path = root / name / "ipc.sock" + sockets.append(path) + processes.append(subprocess.Popen([str(BIN / "omachatd"), "--config", str(config), "--state", str(root / f"{name}-state"), "--socket", str(path)], stdout=log, stderr=log)) + for _ in range(100): + if path.exists(): break + time.sleep(0.05) + keys.append(json.loads(subprocess.check_output([str(BIN / "omachat-ctl"), "--socket", str(path), "status"], timeout=10))["nostr_public_key"]) + receivers = [] + for path in sockets: + stream = socket.socket(socket.AF_UNIX) + stream.settimeout(15) + stream.connect(str(path)) + reader = stream.makefile("rb") + for request in [dict(method="hello", params=dict(minimum_version=2, maximum_version=2)), dict(method="subscribe", params=dict(topics=["messages"]))]: + stream.sendall((json.dumps(dict(version=2, id="test", **request))+"\n").encode()) + response = json.loads(reader.readline()) + assert response["status"] == "ok", response + receivers.append((stream, reader)) + for sender, receiver in [(0, 1), (1, 0)]: + text = f"local encrypted roundtrip {sender} to {receiver}" + sent = json.loads(subprocess.check_output([str(BIN / "omachat-ctl"), "--socket", str(sockets[sender]), "send", f"dm:{keys[receiver]}", text], timeout=15)) + assert sent["delivery"] in ["stored", "queued"], sent + while True: + event = json.loads(receivers[receiver][1].readline()) + if event.get("payload", {}).get("text") == text: + assert event["payload"]["delivery"] == "received" + break + for stream, reader in receivers: + reader.close() + stream.close() + print("PASS: two real daemons exchanged authenticated NIP-17 DMs through localhost Grain") + finally: + for process in processes: + if process.poll() is None: + process.terminate() + process.wait(timeout=15) + log.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/test-tui-pty.py b/scripts/test-tui-pty.py new file mode 100644 index 0000000..d5c4d71 --- /dev/null +++ b/scripts/test-tui-pty.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Exercise the actual binaries, terminal state, live IPC and daemon reattach.""" +import argparse +import codecs +import re +import fcntl +import json +import os +from pathlib import Path +import pty +import select +import signal +import subprocess +import tempfile +import termios +import time + + +_screens = {} + +class Screen: + """Small VT screen model for the cursor-addressed ANSI output under test.""" + def __init__(self): + self.rows = [[" "] * 80 for _ in range(24)] + self.x = self.y = 0 + self.pending = "" + self.decoder = codecs.getincrementaldecoder("utf-8")("replace") + + def feed(self, data): + self.pending += self.decoder.decode(data) + while self.pending: + if self.pending.startswith("\x1b"): + match = re.match(r"\x1b\[([0-9;?]*)([@-~])", self.pending) + if not match: + return + params, command = match.groups() + values = [int(v or 0) for v in params.lstrip("?").split(";")] + if command in ("H", "f"): + self.y = max(0, (values[0] or 1) - 1) + self.x = max(0, ((values[1] if len(values) > 1 else 1) or 1) - 1) + elif command == "J" and values[0] == 2: + self.rows = [[" "] * 80 for _ in range(24)] + self.pending = self.pending[match.end():] + else: + char, self.pending = self.pending[0], self.pending[1:] + if char == "\r": self.x = 0 + elif char == "\n": self.y += 1 + elif char.isprintable(): + if self.y < 24 and self.x < 80: self.rows[self.y][self.x] = char + self.x += 1 + + def text(self): + return "\n".join("".join(row) for row in self.rows) + + +def read_until(fd, needle, seconds=10): + screen = _screens.setdefault(fd, Screen()) + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + if select.select([fd], [], [], 0.1)[0]: + try: screen.feed(os.read(fd, 65536)) + except OSError: break + if needle.decode() in screen.text(): return screen.text() + raise AssertionError(f"terminal never displayed {needle!r}: {screen.text()!r}") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--bin-dir", type=Path, default=Path("target/release")) + args = parser.parse_args() + binaries = args.bin_dir.resolve() + with tempfile.TemporaryDirectory(prefix="omachat-pty-") as directory: + root = Path(directory) + socket = root / "ipc" / "omachat.sock" + config = root / "config.json" + config.write_text(json.dumps({"storage_provider": "file", "joined_geohashes": ["gcpvj"]})) + log = open(root / "daemon.log", "wb") + daemon = None + client = None + + def start_daemon(): + process = subprocess.Popen([str(binaries / "omachatd"), "--config", str(config), "--state", str(root / "state"), "--socket", str(socket)], stdout=log, stderr=log) + for _ in range(100): + if socket.exists(): + return process + if process.poll() is not None: + raise AssertionError("daemon failed to start") + time.sleep(0.05) + process.terminate() + raise AssertionError("daemon socket not ready") + + def ctl(*command): + return subprocess.check_output([str(binaries / "omachat-ctl"), "--socket", str(socket), *command], timeout=10) + + try: + daemon = start_daemon() + for ending in ["detach", "SIGINT", "SIGTERM"]: + master, slave = pty.openpty() + _screens.pop(master, None) + fcntl.ioctl(slave, termios.TIOCSWINSZ, b'\x18\x00\x50\x00\x00\x00\x00\x00') + before = termios.tcgetattr(slave) + + def terminal_session(): + os.setsid() + fcntl.ioctl(0, termios.TIOCSCTTY, 0) + + client = subprocess.Popen([str(binaries / "omachat"), "--socket", str(socket)], stdin=slave, stdout=slave, stderr=slave, preexec_fn=terminal_session) + read_until(master, b"connected") + assert not termios.tcgetattr(slave)[3] & termios.ICANON + marker = f"external-{ending}" + ctl("send", "#gcpvj", marker) + read_until(master, marker.encode()) + if ending == "detach": + daemon.terminate() + daemon.wait(timeout=10) + read_until(master, b"disconnected") + daemon = start_daemon() + read_until(master, b"connected", 15) + ctl("send", "#gcpvj", "after-reconnect") + read_until(master, b"after-reconnect") + os.write(master, b"/detach\r") + else: + client.send_signal(getattr(signal, ending)) + assert client.wait(timeout=10) == 0 + assert termios.tcgetattr(slave) == before, "terminal flags were not restored" + assert daemon.poll() is None, "TUI exit stopped daemon" + os.close(master) + os.close(slave) + client = None + print("PASS: live messages, restart/reattach, detach, SIGINT/SIGTERM and terminal restoration") + finally: + if client and client.poll() is None: + client.kill() + client.wait() + if daemon and daemon.poll() is None: + daemon.terminate() + daemon.wait(timeout=10) + log.close() + + +if __name__ == "__main__": + main() From 8bcdbcf444b1cdbe7299c92a1cf01aca749367a8 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Tue, 8 Sep 2026 22:15:51 +0100 Subject: [PATCH 2/3] fix: make Arch builds link and retain truthful chat display state --- .gitignore | 5 +++++ crates/omachat-tui/src/lib.rs | 10 ++++++++-- crates/omachat-tui/src/main.rs | 5 +++++ crates/omachatd/src/chat_history.rs | 4 ++++ crates/omachatd/src/core.rs | 14 ++++++++++++++ docs/installation.md | 5 +++++ packaging/arch/omachat-git/PKGBUILD | 3 +++ packaging/arch/omachat/PKGBUILD | 3 +++ 8 files changed, 47 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 215bef6..99528fe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ /target/ /conformance/probes/arti/target/ __pycache__/ + +/fuzz/target/ +/fuzz/artifacts/ +/fuzz/corpus/ +/fuzz/Cargo.lock diff --git a/crates/omachat-tui/src/lib.rs b/crates/omachat-tui/src/lib.rs index fd1c4eb..51ea3dd 100644 --- a/crates/omachat-tui/src/lib.rs +++ b/crates/omachat-tui/src/lib.rs @@ -212,8 +212,14 @@ impl UiModel { if let Some(index) = conversation.messages.iter().position(|m| m.id == id) { if payload["deleted"] == true { conversation.messages.remove(index); - } else if let Some(delivery) = delivery(payload) { - conversation.messages[index].delivery = Some(delivery); + } else { + if let Some(delivery) = delivery(payload) { + conversation.messages[index].delivery = Some(delivery); + } + if payload["outgoing"] == true { + conversation.messages[index].outgoing = true; + conversation.messages[index].sender = "you".into(); + } } return; } diff --git a/crates/omachat-tui/src/main.rs b/crates/omachat-tui/src/main.rs index e417d71..0c17d4e 100644 --- a/crates/omachat-tui/src/main.rs +++ b/crates/omachat-tui/src/main.rs @@ -296,6 +296,11 @@ async fn submit(client: &mut Client, model: &mut UiModel, line: &str) -> bool { Ok(Some(command)) => match omachat_ctl::request_with_confirmation(client, command).await { Ok(response) => match response.outcome { ResponseOutcome::Ok { result } => { + if result["erased"] == true { + model.conversations.clear(); + model.selected = 0; + model.scroll_offset = 0; + } model.panic_confirmation_pending = false; model.input.clear(); model.status = result.to_string(); diff --git a/crates/omachatd/src/chat_history.rs b/crates/omachatd/src/chat_history.rs index 6e4360e..ba59c1a 100644 --- a/crates/omachatd/src/chat_history.rs +++ b/crates/omachatd/src/chat_history.rs @@ -69,6 +69,10 @@ impl ChatHistory { let old = self.messages[index] .as_object_mut() .ok_or(CoreError::Encoding)?; + if payload["outgoing"] == true { + old.insert("outgoing".into(), true.into()); + old.insert("sender".into(), "you".into()); + } if let Some(delivery) = payload.get("delivery") && delivery != "received" { diff --git a/crates/omachatd/src/core.rs b/crates/omachatd/src/core.rs index 85657c2..a5666af 100644 --- a/crates/omachatd/src/core.rs +++ b/crates/omachatd/src/core.rs @@ -3086,6 +3086,20 @@ impl DaemonCore { } fn publish_message_event(&self, id: &str, conversation: &str, text: &str, delivery: &str) { + let conversation = if let Ok(geohash) = Geohash::parse(conversation.trim_start_matches('#')) + { + format!("#{geohash}") + } else { + let peer = conversation + .strip_prefix("dm:") + .or_else(|| conversation.strip_prefix("nostr_")) + .unwrap_or(conversation); + if decode_xonly(peer).is_ok() { + format!("dm:{}", peer.to_ascii_lowercase()) + } else { + conversation.to_owned() + } + }; self.publish_topic_event(omachat_proto::ipc::Topic::Messages, serde_json::json!({ "id": id, "conversation": conversation, "text": text, "delivery": delivery, "outgoing": delivery != "received", diff --git a/docs/installation.md b/docs/installation.md index fb28c78..acbec4f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -168,3 +168,8 @@ Overflow or malformed/incompatible input disconnects the client; the TUI resubscribes and obtains a fresh snapshot. Subscribe responses include `status` and `messages`; topic filters govern streamed events. A snapshot and its queued live tail may overlap, so clients must deduplicate by message ID. + +Both Arch recipes disable makepkg-injected C LTO and debug information. Cargo +retains the workspace size-optimized Rust LTO profile; GCC LTO objects from +`ring` are incompatible with Rust's linker and caused the original package +build to fail despite a successful direct Cargo release build. diff --git a/packaging/arch/omachat-git/PKGBUILD b/packaging/arch/omachat-git/PKGBUILD index b4de8f3..60e3e04 100644 --- a/packaging/arch/omachat-git/PKGBUILD +++ b/packaging/arch/omachat-git/PKGBUILD @@ -6,6 +6,9 @@ pkgdesc='Tiny encrypted text collaboration for Omarchy (git)' arch=('x86_64') url='https://github.com/tcballard/OmaChat' license=('0BSD') +# Cargo owns release LTO/stripping. GCC LTO objects from ring cannot be linked +# by rust-lld; do not inject makepkg's C LTO or unused debug information. +options=('!lto' '!debug') depends=('dbus' 'ca-certificates' 'coreutils' 'gcc-libs' 'qrencode') makedepends=('cargo' 'rust' 'git') provides=('omachat') diff --git a/packaging/arch/omachat/PKGBUILD b/packaging/arch/omachat/PKGBUILD index e38ee9f..3b5a23c 100644 --- a/packaging/arch/omachat/PKGBUILD +++ b/packaging/arch/omachat/PKGBUILD @@ -6,6 +6,9 @@ pkgdesc='Tiny encrypted text collaboration for Omarchy' arch=('x86_64') url='https://github.com/tcballard/OmaChat' license=('0BSD') +# Cargo owns release LTO/stripping. GCC LTO objects from ring cannot be linked +# by rust-lld; do not inject makepkg's C LTO or unused debug information. +options=('!lto' '!debug') depends=('dbus' 'ca-certificates' 'coreutils' 'gcc-libs' 'qrencode') makedepends=('cargo' 'rust') source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz") From bf49de52a30cf834929261f499e4e1a09f22bd8c Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Tue, 8 Sep 2026 22:22:03 +0100 Subject: [PATCH 3/3] fix: install package-specific licenses and declare runtime libraries --- packaging/arch/README.md | 6 ++++++ packaging/arch/omachat-git/PKGBUILD | 4 ++-- packaging/arch/omachat/PKGBUILD | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packaging/arch/README.md b/packaging/arch/README.md index e5dd228..3544347 100644 --- a/packaging/arch/README.md +++ b/packaging/arch/README.md @@ -18,3 +18,9 @@ a polkit rule, or publishes anything to the AUR. After an owner-authorized release commit exists, create deterministic source and checksum files with `scripts/package-release.sh 0.0.1 COMMIT OUTPUT_DIR`. Insert that exact SHA-256 into the tagged PKGBUILD before its clean-chroot run. + +`namcap` cannot infer the runtime dependencies on the D-Bus service, native +CA certificate store or the optional `qrencode` subprocess. Those dependencies +are intentional even when its ELF scanner reports them as unused. The recipes +explicitly list glibc/libgcc and install the license under the actual package +name (including `omachat-git`). diff --git a/packaging/arch/omachat-git/PKGBUILD b/packaging/arch/omachat-git/PKGBUILD index 60e3e04..08e3760 100644 --- a/packaging/arch/omachat-git/PKGBUILD +++ b/packaging/arch/omachat-git/PKGBUILD @@ -9,7 +9,7 @@ license=('0BSD') # Cargo owns release LTO/stripping. GCC LTO objects from ring cannot be linked # by rust-lld; do not inject makepkg's C LTO or unused debug information. options=('!lto' '!debug') -depends=('dbus' 'ca-certificates' 'coreutils' 'gcc-libs' 'qrencode') +depends=('dbus' 'ca-certificates' 'glibc' 'libgcc' 'qrencode') makedepends=('cargo' 'rust' 'git') provides=('omachat') conflicts=('omachat') @@ -47,5 +47,5 @@ package() { install -Dm644 packaging/completions/_omachat-ctl "$pkgdir/usr/share/zsh/site-functions/_omachat-ctl" install -Dm644 packaging/completions/_omachat "$pkgdir/usr/share/zsh/site-functions/_omachat" install -Dm644 packaging/completions/*.fish -t "$pkgdir/usr/share/fish/vendor_completions.d" - install -Dm644 LICENSE "$pkgdir/usr/share/licenses/omachat/LICENSE" + install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" } diff --git a/packaging/arch/omachat/PKGBUILD b/packaging/arch/omachat/PKGBUILD index 3b5a23c..e86c117 100644 --- a/packaging/arch/omachat/PKGBUILD +++ b/packaging/arch/omachat/PKGBUILD @@ -9,7 +9,7 @@ license=('0BSD') # Cargo owns release LTO/stripping. GCC LTO objects from ring cannot be linked # by rust-lld; do not inject makepkg's C LTO or unused debug information. options=('!lto' '!debug') -depends=('dbus' 'ca-certificates' 'coreutils' 'gcc-libs' 'qrencode') +depends=('dbus' 'ca-certificates' 'glibc' 'libgcc' 'qrencode') makedepends=('cargo' 'rust') source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz") # Release automation must replace this marker with the published archive hash.