fix history
ober
0aefeed8985ad16c08bae703e3f7ba67effb13af
--- a/Makefile +++ b/Makefile @@ -35,8 +35,10 @@ else TUI_SHIM_EXT := so endif TUI_SHIM := $(TUI_SHIM_DIR)/signal_tui_shim.$(TUI_SHIM_EXT) +LOG_SHIM := signal_log_shim.$(TUI_SHIM_EXT) +SQLCIPHER_PREFIX := $(shell brew --prefix sqlcipher 2>/dev/null) -.PHONY: all build binary run run-tui test install clean help vendor-deps tui-shim ensure-jerboa-tools ensure-jerboa-native ensure-jsqlite +.PHONY: all build binary run run-tui test install install-log-shim clean help vendor-deps tui-shim log-shim ensure-jerboa-tools ensure-jerboa-native ensure-jsqlite .DEFAULT_GOAL := help all: binary @@ -72,11 +74,18 @@ install: binary fi; \ fi test ! -f signal_tui_shim.$(TUI_SHIM_EXT) || install -m 0755 signal_tui_shim.$(TUI_SHIM_EXT) $(BIN_DIR)/signal_tui_shim.$(TUI_SHIM_EXT) + test ! -f $(LOG_SHIM) || install -m 0755 $(LOG_SHIM) $(BIN_DIR)/$(LOG_SHIM) @echo "Installed $(BIN) to $(BIN_DIR)/$(BIN)" +install-log-shim: log-shim + mkdir -p $(BIN_DIR) + install -m 0755 $(LOG_SHIM) $(BIN_DIR)/$(LOG_SHIM) + @echo "Installed legacy $(LOG_SHIM) to $(BIN_DIR)/$(LOG_SHIM)" + clean: rm -f $(BIN) rm -f signal_tui_shim.dylib signal_tui_shim.so + rm -f signal_log_shim.dylib signal_log_shim.so find signal \( -name '*.so' -o -name '*.wpo' \) -delete 2>/dev/null || true vendor-deps: ensure-jsqlite vendor/termbox2 @@ -138,6 +147,17 @@ tui-shim: vendor/termbox2 signal/tui/signal_tui_shim.c cp $(TUI_SHIM) signal_tui_shim.$(TUI_SHIM_EXT) +log-shim: + @if [ -z "$(SQLCIPHER_PREFIX)" ]; then \ + echo "ERROR: SQLCipher is required for legacy message logs; install it with Homebrew or set SQLCIPHER_PREFIX."; \ + exit 1; \ + fi + cc -shared -fPIC \ + -I$(SQLCIPHER_PREFIX)/include/sqlcipher \ + -L$(SQLCIPHER_PREFIX)/lib -lsqlcipher \ + -o $(LOG_SHIM) \ + signal/log_shim.c + help: @echo "jerboa-signal -- Signal client over signal-cli" @echo "" @@ -147,8 +167,10 @@ help: @echo " run-tui Build shim and start the TUI" @echo " test Run tests" @echo " install Install ./jerboa-signal to ~/.local/bin" + @echo " install-log-shim Install SQLCipher shim for legacy logs" @echo " ensure-jerboa-tools Ensure jerboa/jerbuild are available" @echo " tui-shim Build the termbox2 TUI shim" + @echo " log-shim Build the SQLCipher legacy log shim" @echo " clean Remove build artifacts" @echo "" @echo "Prerequisite: signal-cli must be linked to your Signal account." --- a/README.md +++ b/README.md @@ -97,9 +97,10 @@ too. Use Ctrl-Q or Ctrl-C to quit. The TUI can log every message to an encrypted SQLite database so you keep a copy even when the sender later deletes it ("delete for everyone"). Logs use -`jsqlite` for the SQLite engine and a Jerboa-native encrypted container around -the database image; contents and metadata are unreadable without your -passphrase. No native SQLite or SQLCipher library is loaded. +`jsqlite` for new logs and a Jerboa-native encrypted container around the +database image; contents and metadata are unreadable without your passphrase. +Existing SQLCipher logs are still opened through the legacy shim so old history +continues to replay. Enable it: @@ -117,6 +118,15 @@ JSON); remote-deletes, edits, reactions, calls, attachments, and other displayable events are logged as new rows and never overwrite the original. +To convert an older SQLCipher log to the current jsqlite container format: + +```sh +jerboa-signal migrate-log # add -a +PHONE if you use a named account +``` + +The converter verifies the new row count before swapping files and keeps the +old SQLCipher database beside it as `messages-<account>.db.sqlcipher.bak`. + Logging happens only while the TUI is open — messages that arrive while it is closed are not captured. @@ -141,7 +151,7 @@ Modules: - `signal/cmd-tui.ss` — TUI command entry point; see `docs/TUI_PLAN.md` - `signal/store.ss` — persistent local state (deleted-conversation list) - `signal/capture.ss` — normalizes a notification into an encrypted log row -- `signal/logdb.ss` + `signal/log_crypto.ss` — encrypted jsqlite log backend +- `signal/logdb.ss` + `signal/log_crypto.ss` — encrypted log backend - `signal/tui/` — termbox2 FFI and the first terminal shell ## License new file mode 100644 --- /dev/null +++ b/signal/cmd-migrate-log.ss @@ -0,0 +1,79 @@ +#!chezscheme +;;; signal/cmd-migrate-log -- convert legacy SQLCipher logs to jsqlite. + +(library (signal cmd-migrate-log) + (export cmd-migrate-log) + + (import (except (chezscheme) + make-hash-table hash-table? + sort sort! + printf fprintf + path-extension path-absolute? + with-input-from-string with-output-to-string + iota 1+ 1- + partition + make-date make-time) + (except (jerboa prelude) meta atom?) + (signal logdb) + (signal store)) + + (def (cmd-migrate-log account) + (let* ([acct (or account "default")] + [path (messages-store-path acct)] + [env (getenv "JERBOA_SIGNAL_DB_KEY")] + [key (if (and env (not (string=? env ""))) + env + (logdb-prompt-passphrase + "jerboa-signal: passphrase for encrypted message log: "))]) + (cond + [(or (not key) (string=? key "")) + (display "jerboa-signal: migration cancelled; no passphrase entered\n" + (current-error-port)) + (exit 2)] + [else + (ensure-store-dir!) + (report-migration-result + (logdb-migrate-legacy-to-jsqlite path key))]))) + + (def (report-migration-result result) + (case (car result) + [(migrated) + (display "Migrated encrypted log to jsqlite.\n") + (display "Rows copied: ") + (display (list-ref result 3)) + (newline) + (display "New log: ") + (display (list-ref result 1)) + (newline) + (display "Legacy backup: ") + (display (list-ref result 2)) + (newline)] + [(already-jsqlite) + (display "Encrypted log is already in jsqlite format.\n") + (display "Rows: ") + (display (list-ref result 2)) + (newline)] + [(missing) + (display "jerboa-signal: no encrypted log found at " + (current-error-port)) + (display (list-ref result 1) (current-error-port)) + (newline (current-error-port)) + (exit 1)] + [(open-failed) + (display "jerboa-signal: could not open legacy log; wrong passphrase or missing SQLCipher shim\n" + (current-error-port)) + (exit 1)] + [(read-failed) + (display "jerboa-signal: could not read all legacy rows; migration aborted\n" + (current-error-port)) + (exit 1)] + [(verify-failed) + (display "jerboa-signal: converted log failed verification; migration aborted\n" + (current-error-port)) + (exit 1)] + [else + (display "jerboa-signal: unexpected migration result\n" + (current-error-port)) + (exit 1)])) + + ) ;; end library new file mode 100644 --- /dev/null +++ b/signal/log_shim.c @@ -0,0 +1,173 @@ +/* signal_log_shim.c -- SQLCipher-backed encrypted message log for jerboa-signal. + * + * Captures every Signal event as an append-only row so messages survive even + * when the sender later deletes them ("delete for everyone" / remote delete): + * a remote-delete is just another row and never touches the original. + * + * The database is a real encrypted SQLite file (SQLCipher, AES-256) -- contents + * AND metadata are unreadable without the key. + * + * Build: + * cc -shared -fPIC \ + * -I$(brew --prefix sqlcipher)/include/sqlcipher \ + * -L$(brew --prefix sqlcipher)/lib -lsqlcipher \ + * -o signal_log_shim.dylib signal/log_shim.c + */ + +#include <sqlite3.h> +#include <string.h> + +/* SQLCipher exports this, but sqlite3.h only declares it under + * SQLITE_HAS_CODEC. Declare directly so we link against libsqlcipher's symbol + * without depending on the header guard. */ +extern int sqlite3_key(sqlite3 *db, const void *pKey, int nKey); + +static const char *SCHEMA = + "CREATE TABLE IF NOT EXISTS messages (" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " logged_at INTEGER DEFAULT (strftime('%s','now'))," + " account TEXT," + " direction TEXT," + " conversation TEXT," + " sender TEXT," + " timestamp INTEGER," + " kind TEXT," + " body TEXT," + " raw TEXT NOT NULL" + ");" + "CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation);" + "CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(timestamp);"; + +void *signal_log_open(const char *path, const char *key) { + sqlite3 *db = NULL; + char *err = NULL; + + if (sqlite3_open(path, &db) != SQLITE_OK) { + if (db) sqlite3_close(db); + return NULL; + } + if (key && key[0] != '\0') { + if (sqlite3_key(db, key, (int)strlen(key)) != SQLITE_OK) { + sqlite3_close(db); + return NULL; + } + } + if (sqlite3_exec(db, "SELECT count(*) FROM sqlite_master;", + NULL, NULL, &err) != SQLITE_OK) { + if (err) sqlite3_free(err); + sqlite3_close(db); + return NULL; + } + if (sqlite3_exec(db, SCHEMA, NULL, NULL, &err) != SQLITE_OK) { + if (err) sqlite3_free(err); + sqlite3_close(db); + return NULL; + } + (void)sqlite3_exec(db, "PRAGMA journal_mode=WAL;", NULL, NULL, NULL); + return (void *)db; +} + +int signal_log_close(void *handle) { + if (!handle) return 0; + return sqlite3_close((sqlite3 *)handle); +} + +static void bind_text_or_null(sqlite3_stmt *st, int idx, const char *s) { + if (s && s[0] != '\0') + sqlite3_bind_text(st, idx, s, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, idx); +} + +int signal_log_put(void *handle, + const char *account, const char *direction, + const char *conversation, const char *sender, + long long timestamp, const char *kind, + const char *body, const char *raw) { + if (!handle) return -1; + sqlite3 *db = (sqlite3 *)handle; + static const char *SQL = + "INSERT INTO messages" + " (account,direction,conversation,sender,timestamp,kind,body,raw)" + " VALUES (?,?,?,?,?,?,?,?);"; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, SQL, -1, &st, NULL) != SQLITE_OK) + return sqlite3_errcode(db); + bind_text_or_null(st, 1, account); + bind_text_or_null(st, 2, direction); + bind_text_or_null(st, 3, conversation); + bind_text_or_null(st, 4, sender); + sqlite3_bind_int64(st, 5, (sqlite3_int64)timestamp); + bind_text_or_null(st, 6, kind); + bind_text_or_null(st, 7, body); + sqlite3_bind_text(st, 8, raw ? raw : "", -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + return (rc == SQLITE_DONE) ? 0 : rc; +} + +long long signal_log_count(void *handle) { + if (!handle) return -1; + sqlite3 *db = (sqlite3 *)handle; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, "SELECT count(*) FROM messages;", -1, &st, NULL) + != SQLITE_OK) + return -1; + long long n = -1; + if (sqlite3_step(st) == SQLITE_ROW) + n = (long long)sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + return n; +} + +void *signal_log_recent(void *handle, long long limit) { + if (!handle) return NULL; + sqlite3 *db = (sqlite3 *)handle; + static const char *SQL = + "SELECT direction, conversation, sender, timestamp, kind, body" + " FROM messages" + " WHERE direction IN ('in','out')" + " AND conversation IS NOT NULL AND conversation <> ''" + " AND body IS NOT NULL AND body <> ''" + " ORDER BY id DESC LIMIT ?;"; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, SQL, -1, &st, NULL) != SQLITE_OK) + return NULL; + sqlite3_bind_int64(st, 1, (sqlite3_int64)(limit > 0 ? limit : 0)); + return (void *)st; +} + +void *signal_log_all_rows(void *handle) { + if (!handle) return NULL; + sqlite3 *db = (sqlite3 *)handle; + static const char *SQL = + "SELECT logged_at, account, direction, conversation, sender," + " timestamp, kind, body, raw" + " FROM messages" + " ORDER BY id ASC;"; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, SQL, -1, &st, NULL) != SQLITE_OK) + return NULL; + return (void *)st; +} + +int signal_log_row_step(void *stmt) { + if (!stmt) return 0; + return sqlite3_step((sqlite3_stmt *)stmt) == SQLITE_ROW ? 1 : 0; +} + +const char *signal_log_row_text(void *stmt, int col) { + if (!stmt) return ""; + const unsigned char *s = sqlite3_column_text((sqlite3_stmt *)stmt, col); + return s ? (const char *)s : ""; +} + +long long signal_log_row_int(void *stmt, int col) { + if (!stmt) return 0; + return (long long)sqlite3_column_int64((sqlite3_stmt *)stmt, col); +} + +int signal_log_row_close(void *stmt) { + if (!stmt) return 0; + return sqlite3_finalize((sqlite3_stmt *)stmt); +} --- a/signal/logdb.ss +++ b/signal/logdb.ss @@ -2,12 +2,13 @@ ;;; signal/logdb -- encrypted message log. ;;; ;;; New logs use a Jerboa-native jsqlite database image stored inside an -;;; authenticated encrypted container. No native SQLite or SQLCipher shim is -;;; loaded. +;;; authenticated encrypted container. Existing SQLCipher databases are still +;;; opened through the legacy shim so old logs do not disappear. (library (signal logdb) (export logdb-available? logdb-open logdb-close logdb-put logdb-count logdb-recent + logdb-migrate-legacy-to-jsqlite logdb-prompt-passphrase) (import (except (chezscheme) @@ -31,6 +32,132 @@ (defstruct jlog (path key salt db)) ;; -------------------------------------------------------------------------- + ;; Legacy SQLCipher shim. This stays for old messages-*.db files that were + ;; created by the previous backend. + + (def *shim-load-state* (box 'untried)) + + (def (try-load-shim path) + (and (file-exists? path) (load-shared-object path))) + + (def (ensure-shim-loaded!) + (cond + [(eq? (unbox *shim-load-state*) 'loaded) #t] + [(eq? (unbox *shim-load-state*) 'missing) #f] + [else + (let* ([bin-dir (path-directory (car (command-line)))] + [home (or (getenv "HOME") ".")] + [loaded? + (or (foreign-entry? "signal_log_open") + (guard (e [#t #f]) + (or (try-load-shim "signal_log_shim.dylib") + (try-load-shim "signal_log_shim.so") + (try-load-shim (path-join bin-dir "signal_log_shim.dylib")) + (try-load-shim (path-join bin-dir "signal_log_shim.so")) + (try-load-shim (path-join home ".local" "bin" + "signal_log_shim.dylib")) + (try-load-shim (path-join home ".local" "bin" + "signal_log_shim.so")))))]) + (set-box! *shim-load-state* (if loaded? 'loaded 'missing)) + loaded?)])) + + (def (legacy-entry? name) + (or (foreign-entry? name) + (and (ensure-shim-loaded!) (foreign-entry? name)))) + + (def (legacy-available?) + (legacy-entry? "signal_log_open")) + + (def (legacy-open path key) + (and (legacy-entry? "signal_log_open") + (let ([h ((foreign-procedure "signal_log_open" (string string) uptr) + path key)]) + (and (not (= h 0)) h)))) + + (def (legacy-close handle) + (when (and handle (legacy-entry? "signal_log_close")) + ((foreign-procedure "signal_log_close" (uptr) int) handle) + (void))) + + (def (legacy-put handle account direction conversation sender + timestamp kind body raw) + (and handle + (legacy-entry? "signal_log_put") + (= 0 ((foreign-procedure "signal_log_put" + (uptr string string string string integer-64 string string string) + int) + handle account direction conversation sender + timestamp kind body raw)))) + + (def (legacy-count handle) + (if (and handle (legacy-entry? "signal_log_count")) + ((foreign-procedure "signal_log_count" (uptr) integer-64) handle) + -1)) + + (def (legacy-recent handle limit) + (if (and handle + (legacy-entry? "signal_log_recent") + (legacy-entry? "signal_log_row_step") + (legacy-entry? "signal_log_row_text") + (legacy-entry? "signal_log_row_int") + (legacy-entry? "signal_log_row_close")) + (guard (e [#t '()]) + (let ([open (foreign-procedure "signal_log_recent" (uptr integer-64) uptr)] + [step (foreign-procedure "signal_log_row_step" (uptr) int)] + [text (foreign-procedure "signal_log_row_text" (uptr int) string)] + [num (foreign-procedure "signal_log_row_int" (uptr int) integer-64)] + [close (foreign-procedure "signal_log_row_close" (uptr) int)]) + (let ([st (open handle limit)]) + (if (= st 0) + '() + (dynamic-wind + (lambda () (void)) + (lambda () + (let loop ([acc '()]) + (if (= (step st) 1) + (loop (cons (list (text st 0) (text st 1) (text st 2) + (num st 3) (text st 4) (text st 5)) + acc)) + acc))) + (lambda () (close st))))))) + '())) + + (def (legacy-all-rows handle) + (if (and handle + (legacy-entry? "signal_log_all_rows") + (legacy-entry? "signal_log_row_step") + (legacy-entry? "signal_log_row_text") + (legacy-entry? "signal_log_row_int") + (legacy-entry? "signal_log_row_close")) + (guard (e [#t '()]) + (let ([open (foreign-procedure "signal_log_all_rows" (uptr) uptr)] + [step (foreign-procedure "signal_log_row_step" (uptr) int)] + [text (foreign-procedure "signal_log_row_text" (uptr int) string)] + [num (foreign-procedure "signal_log_row_int" (uptr int) integer-64)] + [close (foreign-procedure "signal_log_row_close" (uptr) int)]) + (let ([st (open handle)]) + (if (= st 0) + '() + (dynamic-wind + (lambda () (void)) + (lambda () + (let loop ([acc '()]) + (if (= (step st) 1) + (loop (cons (list (num st 0) + (text st 1) + (text st 2) + (text st 3) + (text st 4) + (num st 5) + (text st 6) + (text st 7) + (text st 8)) + acc)) + (reverse acc)))) + (lambda () (close st))))))) + '())) + + ;; -------------------------------------------------------------------------- ;; jsqlite encrypted container. (def *jlog-magic* (string->utf8 "JSQLITELOGv1\n")) @@ -170,9 +297,16 @@ (def (jlog-put log account direction conversation sender timestamp kind body raw) + (jlog-put-row log (now-seconds) account direction conversation sender + timestamp kind body raw) + (persist-jlog! log) + #t) + + (def (jlog-put-row log logged-at account direction conversation sender + timestamp kind body raw) (let ([db (jlog-db log)]) (sqlite-exec db *insert-sql* - (now-seconds) + logged-at (empty->null account) (empty->null direction) (empty->null conversation) @@ -180,9 +314,7 @@ timestamp (empty->null kind) (empty->null body) - (or raw "")) - (persist-jlog! log) - #t)) + (or raw "")))) (def (jlog-count log) (let ([rows (sqlite-query (jlog-db log) "SELECT count(*) FROM messages")]) @@ -218,10 +350,109 @@ ;; Public API. (def (logdb-available?) - (log-crypto-available?)) + (or (log-crypto-available?) (legacy-available?))) + + (def (backend-env-is? name) + (let ([v (getenv "JERBOA_SIGNAL_LOG_BACKEND")]) + (and v (string-ci=? v name)))) + + (def (wrap-legacy h) + (and h (make-logdb-handle 'legacy h))) + + (def (logdb-migrate-legacy-to-jsqlite path key) + (cond + [(not (file-exists? path)) + (list 'missing path)] + [(open-jlog path key) + => (lambda (h) + (let ([n (logdb-count h)]) + (logdb-close h) + (list 'already-jsqlite path n)))] + [else + (let ([legacy (legacy-open path key)]) + (if legacy + (migrate-open-legacy! path key legacy) + (list 'open-failed path)))])) + + (def (migrate-open-legacy! path key legacy) + (let ([tmp (string-append path ".jsqlite-migrate")] + [backup (unique-backup-path path)]) + (delete-if-exists! tmp) + (let* ([rows (legacy-all-rows legacy)] + [legacy-count (legacy-count legacy)]) + (legacy-close legacy) + (if (not (= legacy-count (length rows))) + (begin + (delete-if-exists! tmp) + (list 'read-failed path legacy-count (length rows))) + (let ([new-h (new-jlog tmp key)]) + (jlog-copy-rows! (logdb-handle-inner new-h) rows) + (logdb-close new-h) + (let ([check-h (open-jlog tmp key)]) + (if (and check-h (= (logdb-count check-h) legacy-count)) + (begin + (logdb-close check-h) + (replace-with-migrated-log! path tmp backup) + (list 'migrated path backup legacy-count)) + (begin + (when check-h (logdb-close check-h)) + (delete-if-exists! tmp) + (list 'verify-failed path legacy-count))))))))) + + (def (jlog-copy-rows! log rows) + (let ([db (jlog-db log)]) + (sqlite-exec db "BEGIN TRANSACTION") + (guard (e [#t + (guard (_ [#t (void)]) + (sqlite-exec db "ROLLBACK")) + (raise e)]) + (for-each + (lambda (row) + (jlog-put-row log + (list-ref row 0) + (list-ref row 1) + (list-ref row 2) + (list-ref row 3) + (list-ref row 4) + (list-ref row 5) + (list-ref row 6) + (list-ref row 7) + (list-ref row 8))) + rows) + (sqlite-exec db "COMMIT")))) + + (def (unique-backup-path path) + (let loop ([n 0]) + (let ([candidate + (if (= n 0) + (string-append path ".sqlcipher.bak") + (string-append path ".sqlcipher.bak." + (number->string n)))]) + (if (file-exists? candidate) + (loop (+ n 1)) + candidate)))) + + (def (replace-with-migrated-log! path tmp backup) + (rename-file path backup) + (guard (e [#t + (when (file-exists? backup) + (guard (_ [#t (void)]) + (rename-file backup path))) + (when (file-exists? tmp) + (delete-if-exists! tmp)) + (error 'logdb-migrate-legacy-to-jsqlite + "failed to install migrated log" e)]) + (rename-file tmp path))) (def (logdb-open path key) - (open-jlog path key)) + (cond + [(or (backend-env-is? "sqlcipher") (backend-env-is? "legacy")) + (wrap-legacy (legacy-open path key))] + [(backend-env-is? "jsqlite") + (open-jlog path key)] + [else + (or (open-jlog path key) + (wrap-legacy (legacy-open path key)))])) (def (logdb-close handle) (when (logdb-handle? handle) @@ -231,6 +462,8 @@ (let ([log (logdb-handle-inner handle)]) (persist-jlog! log) (sqlite-close (jlog-db log))))] + [(legacy) + (legacy-close (logdb-handle-inner handle))] [else (void)]))) (def (logdb-put handle account direction conversation sender @@ -242,6 +475,10 @@ (jlog-put (logdb-handle-inner handle) account direction conversation sender timestamp kind body raw)] + [(legacy) + (legacy-put (logdb-handle-inner handle) + account direction conversation sender + timestamp kind body raw)] [else #f])))) (def (logdb-count handle) @@ -249,6 +486,7 @@ (guard (e [#t -1]) (case (logdb-handle-backend handle) [(jsqlite) (jlog-count (logdb-handle-inner handle))] + [(legacy) (legacy-count (logdb-handle-inner handle))] [else -1])) -1)) @@ -257,6 +495,7 @@ (guard (e [#t '()]) (case (logdb-handle-backend handle) [(jsqlite) (jlog-recent (logdb-handle-inner handle) limit)] + [(legacy) (legacy-recent (logdb-handle-inner handle) limit)] [else '()])) '())) --- a/signal/main.ss +++ b/signal/main.ss @@ -35,7 +35,8 @@ (import (except (jerboa prelude) meta atom?) (signal cmd-send) (signal cmd-listen) - (signal cmd-tui)) + (signal cmd-tui) + (signal cmd-migrate-log)) (def *version* "0.1.0") @@ -47,6 +48,7 @@ Subcommands: send [-a +PHONE] RECIPIENT MESSAGE Send a Signal message listen [-a +PHONE] Stream inbound envelopes as NDJSON tui [-a +PHONE] Start the terminal UI + migrate-log [-a +PHONE] Convert legacy encrypted history log version Print version help Print this help @@ -101,6 +103,15 @@ linking once before using this tool: (exit 2)] [else (cmd-tui account)]))] + [(string=? sub "migrate-log") + (let-values ([(account args) (parse-account-flag rest)]) + (cond + [(not (null? args)) + (display "jerboa-signal: migrate-log takes no positional arguments\n" + (current-error-port)) + (exit 2)] + [else + (cmd-migrate-log account)]))] [else (display "jerboa-signal: unknown subcommand: " (current-error-port)) (display sub (current-error-port)) new file mode 100644 --- /dev/null +++ b/tests/test-history-replay.ss @@ -0,0 +1,76 @@ +#!chezscheme +;;; Focused test for encrypted log replay into a fresh TUI state. + +(import (except (chezscheme) + make-hash-table hash-table? + sort sort! + printf fprintf + path-extension path-absolute? + with-input-from-string with-output-to-string + iota 1+ 1- + partition + make-date make-time) + (except (jerboa prelude) meta atom?) + (signal logdb) + (signal tui main)) + +(def path "/tmp/jerboa-signal-history-replay-test.db") + +(def (delete-if-exists! p) + (when (file-exists? p) (delete-file p))) + +(def (check label pred) + (unless pred + (error 'test-history-replay label))) + +(def (conversation-by-id snapshots id) + (let loop ([xs snapshots]) + (cond + [(null? xs) #f] + [(string=? (list-ref (car xs) 0) id) (car xs)] + [else (loop (cdr xs))]))) + +(delete-if-exists! path) +(delete-if-exists! (string-append path ".tmp")) + +(let ([h (logdb-open path "replay key")]) + (check "open replay log" h) + (check "write inbound direct" + (logdb-put h "acct" "in" "direct:+15550123" "Alice" + 1000 "data" "hello" "{\"type\":\"in\"}")) + (check "write outbound direct" + (logdb-put h "acct" "out" "direct:+15550123" "You" + 1001 "data" "reply" "{\"type\":\"out\"}")) + (check "write group edit" + (logdb-put h "acct" "in" "group:GROUP1" "Bob" + 1002 "edit" "[edited] group hello" + "{\"type\":\"edit\"}")) + (logdb-close h)) + +(let ([h (logdb-open path "replay key")]) + (check "reopen replay log" h) + (let* ([snapshot (history-replay-snapshot h)] + [count (list-ref snapshot 0)] + [convs (list-ref snapshot 1)] + [direct (conversation-by-id convs "direct:+15550123")] + [group (conversation-by-id convs "group:GROUP1")]) + (check "restores all displayable rows" (= count 3)) + (check "restores direct conversation" direct) + (check "restores direct title from sender" + (string=? (list-ref direct 1) "Alice")) + (check "restores direct messages chronologically" + (equal? (list-ref direct 4) + (list (list 'in "Alice" "hello" 1000 'data #f) + (list 'out "You" "reply" 1001 'data #f)))) + (check "restores group conversation" group) + (check "preserves replayed message kind" + (equal? (list-ref group 4) + (list (list 'in "Bob" "[edited] group hello" + 1002 'edit #f))))) + (logdb-close h)) + +(delete-if-exists! path) +(delete-if-exists! (string-append path ".tmp")) + +(display "history replay ok") +(newline)