no-DB mode implementation #94

Manually merged
jabber.developer merged 34 commits from feat/no-db-mode into master 2026-05-05 19:32:42 +00:00
Collaborator

Flat-file database backend (SQLite-free mode)

Adds a pluggable flat-file storage backend for message history, allowing profanity to build and run without the SQLite dependency (./configure --without-sqlite). Includes bidirectional migration commands and comprehensive test coverage.

Architecture

Backend dispatch (database.c, database.h)

The monolithic database.c has been refactored into a vtable-based dispatch layer. A single db_backend_t pointer (active_db_backend) holds the active implementation. All public log_database_*() functions are thin wrappers that forward to the active backend's function pointers:

typedef struct {
    gboolean (*init)(ProfAccount*);
    void     (*close)(void);
    void     (*add_incoming)(ProfMessage*);
    void     (*add_outgoing_chat)(...);
    void     (*add_outgoing_muc)(...);
    db_history_result_t (*get_previous_chat)(..., GSList** result);
    ProfMessage* (*get_limits_info)(...);
    GSList* (*verify_integrity)(...);
} db_backend_t;

log_database_init() reads PREF_DBLOG to select the backend; log_database_switch_backend() closes the current one, persists the preference, and reinitializes with the active account — requiring an active XMPP session to resolve the storage path.

SQLite backend (database_sqlite.c — 965 lines)

Extracted from the old database.c with no behavioral changes. Schema uses ChatLogs table with dual-FK LMC tracking (replaces_db_idreplaced_by_db_id) and an AFTER INSERT trigger to maintain the back-reference. Indexes on (to_jid, from_jid) and timestamp for range queries.

Flat-file backend (database_flatfile.c — 1039 lines)

One UTF-8 text file per contact in flatlog/<account>/<jid_dir>/chat.log. Each line is a single message with pipe-delimited metadata:

2025-06-15T10:30:00Z [chat|none|id:abc123|aid:mam-1|corrects:|to:user@srv|to_res:phone|read:0] buddy@srv/res: Hello world

Key data structures per contact (ff_contact_state_t):

  • Sparse index: Samples every 500th line (FF_INDEX_STEP), storing byte offset + epoch timestamp. Enables O(log n) binary search for time-range queries on a typical 10K-message contact with only ~20 index entries in memory.
  • archive_ids hash set: O(1) MAM dedup — prevents duplicate archive_id on merge/import.
  • stanza_senders hash map: Maps stanza_id → from_jid for O(1) LMC sender validation (rejects corrections from a different JID than the original sender).
  • Stat cache (mtime/size/inode): Detects file changes to decide between incremental extend (append-only) and full rebuild.
  • Fast ID extraction (_ff_cache_line_ids()): Lightweight pass that extracts stanza_id, archive_id, and from_jid without full parsing (no GDateTime allocation, no unescape) — used during index build.

Parser (database_flatfile_parser.c — 780 lines)

Handles escaping, UTF-8 validation, and legacy format support:

  • Backslash-based escaping for message bodies (\n, \r, \\) and metadata values (additionally \|, \]) — chosen over base64 to preserve grep-ability.
  • Sender resource escapes ": " (colon-space) to prevent false message boundary detection.
  • UTF-8 validation with Latin-1 fallback conversion for malformed legacy logs.
  • CRLF tolerance, 10 MB max line length, graceful skip of unparsable lines.
  • Legacy format support (no metadata brackets) for backward compatibility with old chatlog.c files.

Integrity checker (database_flatfile_verify.c — 309 lines)

Multi-level verification returning GSList<integrity_issue_t> with severity:

  • File permissions (warns if ≠ 0600), BOM detection, CRLF warnings
  • UTF-8 validation with byte offset, control character detection
  • Timestamp ordering (out-of-order messages from MAM)
  • Duplicate stanza_id/archive_id detection with line numbers
  • Unparsable line detection via full ff_parse_line() round-trip

Export/import engine (database_export.c — 559 lines, HAVE_SQLITE only)

Bidirectional migration between SQLite and flat-file with merge-sort deduplication:

  1. Reads all messages from both source (query) and destination (parse file)
  2. Dedup key: stanza_id if present, else SHA-256 of timestamp|from_jid|body_prefix
  3. Merge into sorted GSList by ISO-8601 timestamp
  4. Write to temp file, atomic rename (flock() + rename)

Contact discovery reverses the @_at_ JID mapping from the flatlog directory structure.

Security hardening

  • Path traversal: ff_jid_to_dir() replaces /, \ with _; collapses .. to __; rejects empty results. ff_ensure_dir() checks final path component against symlinks via g_lstat().
  • Log injection: All untrusted metadata (stanza_id, archive_id, replace_id, JIDs) escaped on write — \n, \r, |, ], \ all backslash-escaped. Prevents crafted stanza_id from injecting fake log lines.
  • Sender validation: LMC corrections checked against stanza_senders map — rejects corrections from different JID than original sender.
  • Resource limits: 10 MB max line length, 100-depth LMC chain limit.

User-facing commands

Command Description
/history switch sqlite|flatfile Runtime backend switch (requires connection)
/history export [jid] SQLite → flat-file with auto-dedup
/history import [jid] Flat-file → SQLite with auto-dedup
/history verify [jid] Integrity report with file:line references

Build system

  • ./configure --without-sqlite — flat-file only, no SQLite dependency
  • HAVE_SQLITE / BUILD_SQLITE conditionals gate export/import and SQLite backend
  • SQLite ≥ 3.22.0 via pkg-config when enabled

Test coverage (4300+ new test lines)

129 functional tests in 4 balanced parallel groups (~50-53s each):

Group Tests Content
G1 (52s) 44 Connect, Ping, Disco, Roster
G2 (50s) 20 Export/Import core + bidirectional, Receipts
G3 (53s) 35 Presence, Disconnect, DB History, Message, MUC-DB
G4 (53s) 30 Chat Session, MUC, Carbons, Migration Stress
  • 35 export/import functional tests: cross-backend migration, bidirectional merge, LMC corrections, timestamp round-trip (verified via /time chat set iso8601), MUC groupchat, non-UTC timezone, deduplication, negative assertions
  • 12 DB history functional tests: persistence on reopen, contact isolation, special chars, LMC, dialog flow, long messages, newlines
  • 5 base tests hardened with /history on — previously passed by accident due to cumulative PTY buffer matching

483 unit tests including:

  • 54 export/import tests: field parity, encryption types, direction, replace_id, dedup
  • 14 stress tests: rapid writes, large messages (64KB+), LMC chains, concurrent dedup

Test infrastructure: per-test clock_gettime(CLOCK_MONOTONIC) timing with configurable slow/fast thresholds, NEGATIVE_ASSERT_TIMEOUT for absence verification, prof_test_tz override for timezone tests.

Performance

  • send_receipt_request functional test: 60.7s → 1.1s (sha-256 → sha-1 for stanza-id matching in stabber)
  • O(1) dedup/LMC validation via hash caches (replaced O(n) linear scan)
  • g_slist_append()g_slist_prepend() + g_slist_reverse() throughout (O(n²) → O(n))

Files changed

 34 files changed, 8580 insertions(+), 702 deletions(-)
Module Key files Lines
Flat-file backend database_flatfile.c, _parser.c, _verify.c, .h 2 282
SQLite backend database_sqlite.c 965
Export/import database_export.c 559
Dispatcher database.c, database.h 315
Commands cmd_funcs.c, cmd_defs.c, cmd_ac.c +176
Functional tests test_export_import.c, test_history.c, functionaltests.c +2 205
Unit tests test_database_export.c, test_database_stress.c +1 852

closes #48
fixes #48
resolves #48

## Flat-file database backend (SQLite-free mode) Adds a pluggable flat-file storage backend for message history, allowing profanity to build and run without the SQLite dependency (`./configure --without-sqlite`). Includes bidirectional migration commands and comprehensive test coverage. ### Architecture **Backend dispatch (`database.c`, database.h)** The monolithic `database.c` has been refactored into a vtable-based dispatch layer. A single `db_backend_t` pointer (`active_db_backend`) holds the active implementation. All public `log_database_*()` functions are thin wrappers that forward to the active backend's function pointers: ```c typedef struct { gboolean (*init)(ProfAccount*); void (*close)(void); void (*add_incoming)(ProfMessage*); void (*add_outgoing_chat)(...); void (*add_outgoing_muc)(...); db_history_result_t (*get_previous_chat)(..., GSList** result); ProfMessage* (*get_limits_info)(...); GSList* (*verify_integrity)(...); } db_backend_t; ``` `log_database_init()` reads `PREF_DBLOG` to select the backend; `log_database_switch_backend()` closes the current one, persists the preference, and reinitializes with the active account — requiring an active XMPP session to resolve the storage path. **SQLite backend (`database_sqlite.c` — 965 lines)** Extracted from the old `database.c` with no behavioral changes. Schema uses `ChatLogs` table with dual-FK LMC tracking (`replaces_db_id` ↔ `replaced_by_db_id`) and an `AFTER INSERT` trigger to maintain the back-reference. Indexes on `(to_jid, from_jid)` and `timestamp` for range queries. **Flat-file backend (`database_flatfile.c` — 1039 lines)** One UTF-8 text file per contact in `flatlog/<account>/<jid_dir>/chat.log`. Each line is a single message with pipe-delimited metadata: ``` 2025-06-15T10:30:00Z [chat|none|id:abc123|aid:mam-1|corrects:|to:user@srv|to_res:phone|read:0] buddy@srv/res: Hello world ``` Key data structures per contact (`ff_contact_state_t`): - **Sparse index**: Samples every 500th line (`FF_INDEX_STEP`), storing byte offset + epoch timestamp. Enables O(log n) binary search for time-range queries on a typical 10K-message contact with only ~20 index entries in memory. - **`archive_ids` hash set**: O(1) MAM dedup — prevents duplicate archive_id on merge/import. - **`stanza_senders` hash map**: Maps stanza_id → from_jid for O(1) LMC sender validation (rejects corrections from a different JID than the original sender). - **Stat cache** (mtime/size/inode): Detects file changes to decide between incremental extend (append-only) and full rebuild. - **Fast ID extraction** (`_ff_cache_line_ids()`): Lightweight pass that extracts stanza_id, archive_id, and from_jid without full parsing (no GDateTime allocation, no unescape) — used during index build. **Parser (`database_flatfile_parser.c` — 780 lines)** Handles escaping, UTF-8 validation, and legacy format support: - Backslash-based escaping for message bodies (`\n`, `\r`, `\\`) and metadata values (additionally `\|`, `\]`) — chosen over base64 to preserve `grep`-ability. - Sender resource escapes `": "` (colon-space) to prevent false message boundary detection. - UTF-8 validation with Latin-1 fallback conversion for malformed legacy logs. - CRLF tolerance, 10 MB max line length, graceful skip of unparsable lines. - Legacy format support (no metadata brackets) for backward compatibility with old `chatlog.c` files. **Integrity checker (`database_flatfile_verify.c` — 309 lines)** Multi-level verification returning `GSList<integrity_issue_t>` with severity: - File permissions (warns if ≠ 0600), BOM detection, CRLF warnings - UTF-8 validation with byte offset, control character detection - Timestamp ordering (out-of-order messages from MAM) - Duplicate stanza_id/archive_id detection with line numbers - Unparsable line detection via full `ff_parse_line()` round-trip **Export/import engine (`database_export.c` — 559 lines, `HAVE_SQLITE` only)** Bidirectional migration between SQLite and flat-file with merge-sort deduplication: 1. Reads all messages from both source (query) and destination (parse file) 2. Dedup key: stanza_id if present, else SHA-256 of `timestamp|from_jid|body_prefix` 3. Merge into sorted GSList by ISO-8601 timestamp 4. Write to temp file, atomic rename (`flock()` + rename) Contact discovery reverses the `@` → `_at_` JID mapping from the flatlog directory structure. ### Security hardening - **Path traversal**: `ff_jid_to_dir()` replaces `/`, `\` with `_`; collapses `..` to `__`; rejects empty results. `ff_ensure_dir()` checks final path component against symlinks via `g_lstat()`. - **Log injection**: All untrusted metadata (stanza_id, archive_id, replace_id, JIDs) escaped on write — `\n`, `\r`, `|`, `]`, `\` all backslash-escaped. Prevents crafted stanza_id from injecting fake log lines. - **Sender validation**: LMC corrections checked against `stanza_senders` map — rejects corrections from different JID than original sender. - **Resource limits**: 10 MB max line length, 100-depth LMC chain limit. ### User-facing commands | Command | Description | |---------|-------------| | `/history switch sqlite\|flatfile` | Runtime backend switch (requires connection) | | `/history export [jid]` | SQLite → flat-file with auto-dedup | | `/history import [jid]` | Flat-file → SQLite with auto-dedup | | `/history verify [jid]` | Integrity report with file:line references | ### Build system - `./configure --without-sqlite` — flat-file only, no SQLite dependency - `HAVE_SQLITE` / `BUILD_SQLITE` conditionals gate export/import and SQLite backend - SQLite ≥ 3.22.0 via pkg-config when enabled ### Test coverage (4300+ new test lines) **129 functional tests** in 4 balanced parallel groups (~50-53s each): | Group | Tests | Content | |-------|-------|---------| | G1 (52s) | 44 | Connect, Ping, Disco, Roster | | G2 (50s) | 20 | Export/Import core + bidirectional, Receipts | | G3 (53s) | 35 | Presence, Disconnect, DB History, Message, MUC-DB | | G4 (53s) | 30 | Chat Session, MUC, Carbons, Migration Stress | - 35 export/import functional tests: cross-backend migration, bidirectional merge, LMC corrections, timestamp round-trip (verified via `/time chat set iso8601`), MUC groupchat, non-UTC timezone, deduplication, negative assertions - 12 DB history functional tests: persistence on reopen, contact isolation, special chars, LMC, dialog flow, long messages, newlines - 5 base tests hardened with `/history on` — previously passed by accident due to cumulative PTY buffer matching **483 unit tests** including: - 54 export/import tests: field parity, encryption types, direction, replace_id, dedup - 14 stress tests: rapid writes, large messages (64KB+), LMC chains, concurrent dedup **Test infrastructure**: per-test `clock_gettime(CLOCK_MONOTONIC)` timing with configurable slow/fast thresholds, `NEGATIVE_ASSERT_TIMEOUT` for absence verification, `prof_test_tz` override for timezone tests. ### Performance - `send_receipt_request` functional test: 60.7s → 1.1s (sha-256 → sha-1 for stanza-id matching in stabber) - O(1) dedup/LMC validation via hash caches (replaced O(n) linear scan) - `g_slist_append()` → `g_slist_prepend()` + `g_slist_reverse()` throughout (O(n²) → O(n)) ### Files changed ``` 34 files changed, 8580 insertions(+), 702 deletions(-) ``` | Module | Key files | Lines | |--------|-----------|-------| | Flat-file backend | `database_flatfile.c`, `_parser.c`, `_verify.c`, `.h` | 2 282 | | SQLite backend | `database_sqlite.c` | 965 | | Export/import | `database_export.c` | 559 | | Dispatcher | `database.c`, database.h | 315 | | Commands | `cmd_funcs.c`, `cmd_defs.c`, `cmd_ac.c` | +176 | | Functional tests | test_export_import.c, `test_history.c`, functionaltests.c | +2 205 | | Unit tests | `test_database_export.c`, `test_database_stress.c` | +1 852 | closes #48 fixes #48 resolves #48
jabber.developer2 added 1 commit 2026-02-17 14:42:20 +00:00
feat: add flat-file database backend for message history
Some checks failed
CI Code / Check spelling (pull_request) Failing after 17s
CI Code / Check coding style (pull_request) Failing after 33s
CI Code / Code Coverage (pull_request) Failing after 1m1s
CI Code / Linux (arch) (pull_request) Failing after 2m38s
CI Code / Linux (debian) (pull_request) Successful in 6m14s
CI Code / Linux (ubuntu) (pull_request) Successful in 6m20s
a3611b180f
Add pluggable storage backend abstraction (vtable) to the database layer,
allowing selection between SQLite (default) and a new flat-file backend
that stores messages as human-readable plain text files.

New files:
- database_sqlite.c: extracted SQLite backend from database.c
- database_flatfile.c: plain text backend with tolerant parser,
  LMC correction chains, UTF-8/BOM/CRLF handling, integrity checks

Commands:
- /privacy logging flatfile — switch to flat-file backend
- /history verify [<jid>] — check integrity of stored history

Fixes:
- Add missing 'off' guard in flatfile backend
- Enable CHLOG/HISTORY prefs when switching to flatfile mode

Logs stored in ~/.local/share/profanity/flatlog/{account}/{contact}/{date}.log
Format: {ISO8601} [{type}|{enc}|id:{id}|corrects:{id}] {sender}: {message}

Updated: CHANGELOG, CONTRIBUTING.md, profrc.example, man page, cmd_defs,
Makefile.am, test stubs, functional test support (PROF_FLATFILE=1)
jabber.developer2 added 1 commit 2026-02-17 16:36:14 +00:00
fix(flatfile): harden flat-file backend against injection and traversal attacks
Some checks failed
CI Code / Linux (ubuntu) (pull_request) Has been cancelled
CI Code / Check coding style (pull_request) Has been cancelled
CI Code / Check spelling (pull_request) Has been cancelled
CI Code / Code Coverage (pull_request) Has been cancelled
CI Code / Linux (arch) (pull_request) Has been cancelled
CI Code / Linux (debian) (pull_request) Has been cancelled
ba515bdfe8
Security fixes for 7 vulnerabilities in database_flatfile.c:

1. Path traversal via crafted JID (HIGH):
   _ff_jid_to_dir() now strips '/', '\' -> '_' and collapses '..' -> '__',
   preventing a malicious federated JID from escaping the log directory.

2. Log injection via unescaped message body (HIGH):
   Add _ff_escape_message()/_ff_unescape_message() -- escape \n, \r, \\
   in message text on write, unescape on read. Prevents remote contacts
   from injecting fake log lines with forged sender/timestamp/encryption.

3. Metadata field injection (HIGH):
   Add _ff_escape_meta_value()/_ff_unescape_meta_value() -- escape |, ],
   \\, \n, \r in stanza_id/archive_id/replace_id. Parser uses escape-aware
   _ff_find_unescaped_char() and _ff_split_meta() instead of strchr/strsplit.

4. Sender ": " parsing confusion (MEDIUM):
   Escape ": " -> "\: " in XMPP resource on write. Parser uses
   _ff_find_unescaped_colonspace() + _ff_unescape_sender_resource().

5. LMC correction chain cycle -> infinite loop (MEDIUM):
   Add visited hash-set and FF_MAX_LMC_DEPTH (100) limit to chain walker.

6. Unbounded getline() -> OOM (MEDIUM):
   Add FF_MAX_LINE_LEN (10 MB) cap in _ff_readline(); overlength lines
   are skipped with a warning. Replaces all char buf[8192]/fgets() sites
   with dynamic getline() + truncated-line detection at EOF.

7. Symlink attack + TOCTOU on file creation (MEDIUM):
   Replace fopen("a") with open(O_WRONLY|O_APPEND|O_CREAT|O_EXCL|O_NOFOLLOW)
   + fdopen() -- atomic new-file detection, symlink rejection via O_NOFOLLOW.
   _ff_ensure_dir() checks g_lstat() for symlinks. File permissions 0600
   set via open() mode, not post-hoc g_chmod().
jabber.developer2 force-pushed feat/no-db-mode from ba515bdfe8 to 32fe234744 2026-02-17 16:38:00 +00:00 Compare
jabber.developer2 force-pushed feat/no-db-mode from 32fe234744 to 6f8832377a 2026-02-17 16:53:26 +00:00 Compare
jabber.developer2 added 1 commit 2026-02-18 14:17:58 +00:00
refactor: split database_flatfile.c into functional modules
Some checks failed
CI Code / Linux (arch) (pull_request) Has been cancelled
CI Code / Linux (debian) (pull_request) Has been cancelled
CI Code / Linux (ubuntu) (pull_request) Has been cancelled
CI Code / Check coding style (pull_request) Has been cancelled
CI Code / Check spelling (pull_request) Has been cancelled
CI Code / Code Coverage (pull_request) Has been cancelled
1b349fecb5
Split the monolithic database_flatfile.c into:
- database_flatfile.h: internal header with shared types and prototypes
- database_flatfile_parser.c: parsing, escaping, IO helpers
- database_flatfile_verify.c: integrity verification logic
- database_flatfile.c: core backend (init, write, read, LMC, vtable)
Author
Collaborator

Benchmark: daily files vs single file storage

Test setup:

  • 1 contact, 2 years of history (Feb 2024 — Feb 2026)
  • 5 days/week, ~500 messages/day = ~261k messages, ~30 MB total
  • Messages: mixed languages, 5% long (200+ chars), 3% LMC corrections
  • Generated by gen_fake_history.sh (deterministic, reproducible)

Two layouts compared:

  • daily-files (current): {contact}/{YYYY-MM-DD}.log — 522 files, ~60 KB each
  • single-file (alternative): {contact}.log — 1 file, 30 MB

Methodology:

  • Python benchmark (bench_history.py) emulates _flatfile_get_previous_chat(): file listing → read & parse → LMC correction → pagination (last 51 messages)
  • NVMe warm: 5 runs best-of, files in OS page cache
  • NVMe cold: posix_fadvise(DONTNEED) evicts page cache before each read — measures real disk I/O on NVMe
  • HDD simulation: cache eviction + injected sleep() for 7200 RPM characteristics (8 ms seek, 4.17 ms rotational, 150 MB/s sequential). Fragmentation modeled as extra seek per 64 KB fragment boundary

Results (best of N runs):

Scenario Disk Daily Single Winner
1 week, 1 year ago NVMe warm 8 ms 632 ms daily 83x
NVMe cold 7 ms 629 ms daily 86x
HDD 92 ms 887 ms daily 10x
HDD fragmented 93 ms 4 628 ms daily 50x
Last week NVMe warm 8 ms 617 ms daily 76x
NVMe cold 8 ms 624 ms daily 74x
HDD 112 ms 881 ms daily 8x
HDD fragmented 105 ms 4 624 ms daily 44x
Full scan (no filter) NVMe warm 836 ms 810 ms ≈ tie
NVMe cold 838 ms 804 ms ≈ tie
HDD 10 086 ms 1 051 ms single 10x
HDD fragmented 10 327 ms 4 811 ms single 2x

Conclusions:

  • Daily-file layout wins 8–86x on all realistic queries (date-filtered) across all disk types — file names encode dates, irrelevant files are skipped without reading
  • NVMe cold ≈ NVMe warm — bottleneck is CPU parsing, not I/O (60 KB file reads in ~0.04 ms on NVMe)
  • Single-file only wins on full scan (no date filter), which never occurs in practice — get_previous_chat() always passes a time range
  • Fragmented HDD is catastrophic for single-file: 30 MB / 64 KB = ~460 extra seeks × 8 ms = 3.7 s overhead
  • Small daily files (60 KB) are immune to fragmentation — fit in 1–2 fragments
### Benchmark: daily files vs single file storage **Test setup:** - 1 contact, 2 years of history (Feb 2024 — Feb 2026) - 5 days/week, ~500 messages/day = **~261k messages, ~30 MB total** - Messages: mixed languages, 5% long (200+ chars), 3% LMC corrections - Generated by gen_fake_history.sh (deterministic, reproducible) **Two layouts compared:** - **daily-files** (current): `{contact}/{YYYY-MM-DD}.log` — 522 files, ~60 KB each - **single-file** (alternative): `{contact}.log` — 1 file, 30 MB **Methodology:** - Python benchmark (bench_history.py) emulates `_flatfile_get_previous_chat()`: file listing → read & parse → LMC correction → pagination (last 51 messages) - **NVMe warm**: 5 runs best-of, files in OS page cache - **NVMe cold**: `posix_fadvise(DONTNEED)` evicts page cache before each read — measures real disk I/O on NVMe - **HDD simulation**: cache eviction + injected `sleep()` for 7200 RPM characteristics (8 ms seek, 4.17 ms rotational, 150 MB/s sequential). Fragmentation modeled as extra seek per 64 KB fragment boundary **Results (best of N runs):** | Scenario | Disk | Daily | Single | Winner | |---|---|---:|---:|---| | 1 week, 1 year ago | NVMe warm | **8 ms** | 632 ms | daily 83x | | | NVMe cold | **7 ms** | 629 ms | daily 86x | | | HDD | **92 ms** | 887 ms | daily 10x | | | HDD fragmented | **93 ms** | 4 628 ms | daily 50x | | Last week | NVMe warm | **8 ms** | 617 ms | daily 76x | | | NVMe cold | **8 ms** | 624 ms | daily 74x | | | HDD | **112 ms** | 881 ms | daily 8x | | | HDD fragmented | **105 ms** | 4 624 ms | daily 44x | | Full scan (no filter) | NVMe warm | 836 ms | **810 ms** | ≈ tie | | | NVMe cold | 838 ms | **804 ms** | ≈ tie | | | HDD | 10 086 ms | **1 051 ms** | single 10x | | | HDD fragmented | 10 327 ms | **4 811 ms** | single 2x | **Conclusions:** - Daily-file layout wins **8–86x** on all realistic queries (date-filtered) across all disk types — file names encode dates, irrelevant files are skipped without reading - NVMe cold ≈ NVMe warm — bottleneck is CPU parsing, not I/O (60 KB file reads in ~0.04 ms on NVMe) - Single-file only wins on full scan (no date filter), which never occurs in practice — `get_previous_chat()` always passes a time range - Fragmented HDD is catastrophic for single-file: 30 MB / 64 KB = ~460 extra seeks × 8 ms = 3.7 s overhead - Small daily files (60 KB) are immune to fragmentation — fit in 1–2 fragments
jabber.developer2 force-pushed feat/no-db-mode from 1b349fecb5 to 59fe15f4d0 2026-02-18 16:58:59 +00:00 Compare
jabber.developer2 added 1 commit 2026-02-19 13:11:21 +00:00
database_flatfile: single-file storage with sparse index
Some checks failed
CI Code / Check spelling (pull_request) Successful in 20s
CI Code / Check coding style (pull_request) Failing after 33s
CI Code / Code Coverage (pull_request) Successful in 4m50s
CI Code / Linux (debian) (pull_request) Successful in 6m14s
CI Code / Linux (ubuntu) (pull_request) Successful in 6m23s
CI Code / Linux (arch) (pull_request) Successful in 6m29s
f8ef504a09
Replace per-contact directory structure with a single flat file per contact.
Each file uses pipe-delimited records with a sparse byte-offset index
that is rebuilt on demand and cached in memory.

- Rewrite _ff_write_record / _ff_read_records for single-file format
- Add sparse index (every Nth record) for O(log N) seek on history read
- Cursor-based pagination: _ff_get_previous_chat uses saved byte offset
- LMC corrections applied in-place during read (_ff_apply_lmc)
- Newline escaping (\n) in message bodies for line-based storage
- Simplify verify: parse + timestamp order + LMC reference check
- Update parser helpers for new field layout
jabber.developer2 added 1 commit 2026-02-21 13:16:15 +00:00
fix: harden flatfile backend, make SQLite optional
Some checks failed
CI Code / Check spelling (pull_request) Successful in 21s
CI Code / Check coding style (pull_request) Failing after 32s
CI Code / Code Coverage (pull_request) Successful in 4m50s
CI Code / Linux (debian) (pull_request) Successful in 6m13s
CI Code / Linux (ubuntu) (pull_request) Successful in 7m14s
CI Code / Linux (arch) (pull_request) Successful in 11m2s
8868b79617
Build system:
- Add --without-sqlite configure flag (AM_CONDITIONAL BUILD_SQLITE)
- Guard database_sqlite.c with HAVE_SQLITE in Makefile.am, database.c,
  database.h and stub_database.c
- Fall back to flatfile when SQLite not compiled in

Security (database_flatfile.c):
- MAM dedup: skip incoming messages with duplicate stanza-id (archive_id)
- LMC sender validation: reject corrections from mismatched JIDs
- Add flock() advisory locking to prevent interleaved writes from
  concurrent instances
- Check fprintf return when writing file header

Autocomplete (cmd_ac.c):
- Add 'flatfile' to /privacy logging autocomplete
- Add dedicated /history autocomplete with on/off/verify (was boolean-only)

Code quality:
- Fix fwrite return type: size_t not ssize_t (database_flatfile_parser.c)
- Fix mixed allocators in ff_readline: use malloc() consistently for
  overlength-line fallback instead of g_strdup()
- Fix index alignment in _ff_state_extend: use local counter so index
  step doesn't depend on total_lines from initial build

Documentation:
- Fix page: correct directory structure (history.log not per-day files)
- Fix page: add aid:{archive_id} to line format example
- Fix page: missing newline before .SH BUGS
jabber.developer2 force-pushed feat/no-db-mode from 8868b79617 to 0f9157bd3a 2026-02-21 13:29:20 +00:00 Compare
jabber.developer2 added 1 commit 2026-02-21 14:43:55 +00:00
feat(draft): add /history export|import for SQLite<->flatfile migration
All checks were successful
CI Code / Check spelling (pull_request) Successful in 18s
CI Code / Check coding style (pull_request) Successful in 31s
CI Code / Code Coverage (pull_request) Successful in 4m48s
CI Code / Linux (debian) (pull_request) Successful in 6m11s
CI Code / Linux (ubuntu) (pull_request) Successful in 6m16s
CI Code / Linux (arch) (pull_request) Successful in 6m28s
899d5dfe61
DRAFT — not yet tested end-to-end with a live XMPP session.

New commands:
  /history export [<jid>]  — copy messages from SQLite to flat-file
  /history import [<jid>]  — copy messages from flat-file to SQLite
Both merge with existing data; duplicates are skipped using stanza-id
or a SHA-256 fallback key (timestamp + sender + body prefix).

Implementation (src/database_export.c):
- Export: paginate SQLite via get_previous_chat, read existing flatfile
  for dedup, write merged result via ff_write_line + atomic rename
- Import: parse flatfile lines via ff_parse_line, build dedup set from
  SQLite, insert new messages via add_incoming (preserves original
  timestamps for both directions)
- List all contacts: db_sqlite_list_contacts() queries UNION of
  DISTINCT from_jid/to_jid; flatfile enumerates flatlog directories

Wiring:
- database.h: declare export/import functions + db_sqlite_list_contacts
- database_sqlite.c: add db_sqlite_list_contacts()
- cmd_defs.c: add export/import to /history synopsis and args
- cmd_funcs.c: add export/import handlers in cmd_history()
- cmd_ac.c: add 'export' and 'import' to history_ac
- Makefile.am: add database_export.c to core_sources
- stub_database.c: add stubs for test linking
- profanity.1: document export/import in man page

All code guarded with #ifdef HAVE_SQLITE — builds cleanly without it.
jabber.developer2 added 1 commit 2026-02-26 11:05:35 +00:00
feat: add /history switch for runtime database backend switching
Some checks failed
CI Code / Check coding style (pull_request) Successful in 47s
CI Code / Check spelling (pull_request) Successful in 55s
CI Code / Linux (ubuntu) (pull_request) Failing after 2m46s
CI Code / Code Coverage (pull_request) Failing after 5m17s
CI Code / Linux (debian) (pull_request) Failing after 6m3s
CI Code / Linux (arch) (pull_request) Failing after 7m21s
650686c241
Add log_database_switch_backend() that closes the current backend,
updates PREF_DBLOG preference, and reinitializes with the new backend
without requiring a reconnect or restart.

New command: /history switch sqlite|flatfile
- Validates connection state and backend name
- Skips if already using the requested backend
- Autocomplete support for the subcommand
jabber.developer2 force-pushed feat/no-db-mode from 650686c241 to 6f543e124d 2026-02-26 11:17:56 +00:00 Compare
jabber.developer2 added 3 commits 2026-03-04 15:45:54 +00:00
tests: add functional tests for DB message persistence, rebalance groups
All checks were successful
CI Code / Check spelling (pull_request) Successful in 19s
CI Code / Check coding style (pull_request) Successful in 33s
CI Code / Code Coverage (pull_request) Successful in 5m31s
CI Code / Linux (debian) (pull_request) Successful in 6m51s
CI Code / Linux (ubuntu) (pull_request) Successful in 6m54s
CI Code / Linux (arch) (pull_request) Successful in 7m9s
b289d1a753
Add backend-agnostic functional tests for database message persistence.
All tests work with both SQLite and flat-file backends.

New tests in test_history.c:
- message_db_history_on_reopen: basic incoming write+read round-trip
- message_db_history_multiple: 3 messages from different resources
- message_db_history_contact_isolation: buddy1 msg absent from buddy2
- message_db_history_special_chars: XML entities survive decode+DB
- message_db_history_outgoing: sent message persists across reopen
- message_db_history_dialog: outgoing + incoming both in history
- message_db_history_empty: no crash on contact with no history
- message_db_history_long_message: 1000+ char body not truncated
- message_db_history_newline: embedded LF stored correctly
- message_db_history_service_chars: backslash pipe percent braces etc
- message_db_history_verify: /history verify (ifdef HAVE_HISTORY_VERIFY)
- message_db_history_lmc: XEP-0308 correction replaces original in DB

Rebalance test groups for parallel execution (19/22/22/21):
- Group 1: Connect, Ping, Rooms, Software, LastActivity
- Group 2: Message, Receipts, Roster, DB History
- Group 3: Chat Session, Presence, Disconnect
- Group 4: MUC, Carbons
Add functional and unit tests for DB export/import
All checks were successful
CI Code / Check coding style (pull_request) Successful in 35s
CI Code / Check spelling (pull_request) Successful in 44s
CI Code / Code Coverage (pull_request) Successful in 6m14s
CI Code / Linux (ubuntu) (pull_request) Successful in 7m37s
CI Code / Linux (arch) (pull_request) Successful in 7m41s
CI Code / Linux (debian) (pull_request) Successful in 10m22s
081de77593
- 2 functional tests: export_sqlite_to_flatfile, import_flatfile_to_sqlite
  with XEP-0203 delay timestamps for deterministic verification
- 27 unit tests for database_export covering edge cases
- Merge test/db-functional-tests: 12 DB persistence tests (test_history)
- Group 2 rebalanced: 25 tests (23 base + 2 SQLite-only export/import)
- #ifdef HAVE_SQLITE guards for export/import tests
- prof_stop() helper in proftest.c
- Makefile.am: source ordering cleanup, new test files added
jabber.developer2 force-pushed feat/no-db-mode from 081de77593 to 9183ec7c2a 2026-03-04 15:58:11 +00:00 Compare
jabber.developer2 added 1 commit 2026-03-09 09:17:09 +00:00
feat: complete field parity, harden export/import, add tests
Some checks failed
CI Code / Check spelling (pull_request) Successful in 24s
CI Code / Check coding style (pull_request) Failing after 35s
CI Code / Code Coverage (pull_request) Successful in 7m3s
CI Code / Linux (debian) (pull_request) Successful in 8m32s
CI Code / Linux (ubuntu) (pull_request) Successful in 8m38s
CI Code / Linux (arch) (pull_request) Successful in 8m53s
fa2b21152f
Export/Import improvements:
- Replace pagination with direct SQL query (db_sqlite_get_all_chat)
- Wrap import in SQL transaction with rollback on error
- Add fsync before fclose in export for data safety
- Sort merged output by timestamp with secondary key (stanza_id, from_jid)
- Export archive_id and marked_read from SQLite (lossless migration)
- Add progress indication every 500 messages during write/import
- Expand dedup key body prefix from 64 to 256 chars
- Fix g_slist_append O(n²) → g_slist_prepend + g_slist_reverse O(n)

Field parity (to_jid, to_resource, marked_read):
- Add fields to ff_parsed_line_t struct
- Write/parse to:|to_res:|read: metadata tags in flatfile format
- Pass to_resource through _ff_add_message and all callers
- Add marked_read to ProfMessage struct with -1 default (unset)
- Preserve fields across export/import round-trips

Tests (19 new: 11 unit + 8 functional):
- Unit: to_jid_and_marked_read, bracket_in_stanza_id, backslash_in_resource,
  mucpm_type, all_enc_types, crlf_handling, to_jid_special_chars,
  multiple_lines, parsed_line_free_null_safe, no_space_rejected,
  unclosed_bracket
- Functional: export_idempotent_no_duplicates, export_lmc_correction_survives,
  switch_preserves_old_backend_data, export_all_contacts,
  import_double_dedup, verify_after_export,
  switch_backends_independent_messages, export_empty_contact
- Rebalance test groups: move Chat Session from Group 3 to Group 4
  (25/33/30/27 instead of 25/33/36/21)
- Remove hardcoded test counts from group comments

Man page:
- Document /history switch sqlite|flatfile
jabber.developer2 force-pushed feat/no-db-mode from fa2b21152f to 16476676f0 2026-03-09 09:55:59 +00:00 Compare
jabber.developer2 added 1 commit 2026-03-12 07:15:35 +00:00
perf: O(1) dedup/LMC cache, g_slist_append → prepend+reverse
All checks were successful
CI Code / Check spelling (pull_request) Successful in 20s
CI Code / Check coding style (pull_request) Successful in 35s
CI Code / Linux (ubuntu) (pull_request) Successful in 8m48s
CI Code / Linux (debian) (pull_request) Successful in 8m50s
CI Code / Code Coverage (pull_request) Successful in 11m14s
CI Code / Linux (arch) (pull_request) Successful in 13m54s
1583b8de9c
- Add archive_ids hash set and stanza_senders hash map to
  ff_contact_state_t, populated during index build/extend via
  lightweight _ff_cache_line_ids() (no full parse overhead).
- Replace O(n) file scans in _ff_has_archive_id() and
  _ff_find_original_sender() with O(1) hash table lookups.
- Replace g_slist_append with g_slist_prepend + g_slist_reverse
  in _ff_apply_lmc, _flatfile_get_previous_chat, and
  ff_verify_integrity to avoid O(n²) list building.
- Add db_sqlite_last_changes() declaration to database.h.
jabber.developer2 added 1 commit 2026-03-12 08:14:37 +00:00
test: add database stress tests (14 tests, rapid writes, large messages, LMC chains, dedup)
All checks were successful
CI Code / Check spelling (pull_request) Successful in 20s
CI Code / Check coding style (pull_request) Successful in 35s
CI Code / Code Coverage (pull_request) Successful in 7m8s
CI Code / Linux (debian) (pull_request) Successful in 8m51s
CI Code / Linux (ubuntu) (pull_request) Successful in 8m54s
CI Code / Linux (arch) (pull_request) Successful in 13m58s
0274f52943
jabber.developer2 force-pushed feat/no-db-mode from 0274f52943 to 0d38a0a2cb 2026-03-12 15:58:57 +00:00 Compare
jabber.developer2 added 1 commit 2026-03-12 18:02:57 +00:00
fix(functests): fix send_receipt_request 60s timeout, rebalance groups, add per-test timing
All checks were successful
CI Code / Check spelling (pull_request) Successful in 19s
CI Code / Check coding style (pull_request) Successful in 35s
CI Code / Code Coverage (pull_request) Successful in 2m28s
CI Code / Linux (debian) (pull_request) Successful in 4m23s
CI Code / Linux (arch) (pull_request) Successful in 4m41s
CI Code / Linux (ubuntu) (pull_request) Successful in 5m14s
2b1b70665b
Fix send_receipt_request test that wasted 60s on every run:
- Change caps hash from sha-256 to sha-1 (profanity only supports sha-1
  for standard caps path, sha-256 goes to unsupported-hash fallback)
- Fix expected output: 'Buddy1 is online' → 'Buddy1 (laptop) is online'
  (resource is always shown for full JID presence)
- Wrap prof_output_exact in assert_true so mismatches fail fast instead
  of silently waiting the full expect_timeout

Rebalance test groups for parallel execution:
- Move Disco tests (14) from Group 3 → Group 1
- Move DB History tests (12) from Group 2 → Group 3
- Before: G1=31s G2=63s G3=30s G4=37s (bottleneck 63s)
- After:  G1=46s G2=38s G3=40s G4=36s (bottleneck 46s, -27%)

Add per-test wall-clock timing via clock_gettime(CLOCK_MONOTONIC)
in init_prof_test/close_prof_test for profiling individual tests.
jabber.developer2 added 1 commit 2026-03-19 16:06:05 +00:00
test(functests): migration/MUC/timestamp tests, group rebalancing
All checks were successful
CI Code / Check spelling (pull_request) Successful in 24s
CI Code / Check coding style (pull_request) Successful in 41s
CI Code / Linux (ubuntu) (pull_request) Successful in 6m56s
CI Code / Linux (debian) (pull_request) Successful in 7m0s
CI Code / Code Coverage (pull_request) Successful in 7m5s
CI Code / Linux (arch) (pull_request) Successful in 9m29s
58002409ff
Add 11 new export/import tests (24 → 35): 4 MUC database, 3 timestamp
verification, 3 migration stress, 1 LMC correction with negative assert.

Add delay-stamp timestamp checks to 5 base tests via /time chat set
iso8601 and /history on (previously passed by accident from PTY buffer).

Replace magic numbers: NEGATIVE_ASSERT_TIMEOUT, MIN_ELAPSED_INIT.
Add per-test slow/fast thresholds and TZ override (prof_test_tz).

Rebalance groups by whole logical blocks (spread 46s → 3s):
G1=52s G2=50s G3=53s G4=53s. All 129 tests pass.
jabber.developer approved these changes 2026-03-25 13:39:43 +00:00
jabber.developer left a comment
Owner

While the code has a lot of merits, certain parts should be adjusted to follow the best practices, as well as to correct logic. Specific parts would require further discussion.

While the code has a lot of merits, certain parts should be adjusted to follow the best practices, as well as to correct logic. Specific parts would require further discussion.
Makefile.am Outdated
@@ -188,3 +197,2 @@
tests/functionaltests/test_lastactivity.c tests/functionaltests/test_lastactivity.h \
tests/functionaltests/test_autoping.c tests/functionaltests/test_autoping.h \
tests/functionaltests/test_disco.c tests/functionaltests/test_disco.h \
tests/functionaltests/test_autoping.c tests/functionaltests/test_autoping.h \

Is it a wrong offset or is it gitea's UI bug? It appears that the line is overly offset to the right, violating general formatting

Is it a wrong offset or is it gitea's UI bug? It appears that the line is overly offset to the right, violating general formatting
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -97,2 +97,2 @@
PKG_CHECK_MODULES([SQLITE], [sqlite3 >= 3.22.0], [],
[AC_MSG_ERROR([sqlite3 3.22.0 or higher is required])])
### sqlite (optional — can be disabled with --without-sqlite)
AC_ARG_WITH([sqlite],

General note (I am checking all the changed from top to bottom, this change is on the top): is this option supported by the code: does it switch by default to flatfile if sqlite is not available; does it prevent user from switching to sqlite manually.

General note (I am checking all the changed from top to bottom, this change is on the top): is this option supported by the code: does it switch by default to flatfile if sqlite is not available; does it prevent user from switching to sqlite manually.
docs/profanity.1 Outdated
@@ -198,3 +198,3 @@
is stored in
.I $XDG_CONFIG_HOME/profanity/profrc
, details on commands for configuring Profanity can be found at <https://profanity-im.github.io/reference.html> or the respective built\-in help or man pages.
, details on commands for configuring Profanity can be found at <https://profanity-im.github.io/reference.html> or the respective built\-in help or man pages..SS Message History Storage

Since the line is changed, it can also lead to the docs on our website, https://jabber.space/command-reference/

Also, why ".SS" is on the same page?

Since the line is changed, it can also lead to the docs on our website, https://jabber.space/command-reference/ Also, why ".SS" is on the same page?
Author
Collaborator

.SS fixed and link to jabber.space/command-reference/ added

.SS fixed and link to jabber.space/command-reference/ added
jabber.developer marked this conversation as resolved
profrc.example Outdated
@@ -49,6 +49,13 @@ grlog=true
maxsize=1048580
rotate=true
shared=true
# Database backend for message history:

While the comment is useful, it violates general style of this example. I suggest moving documentation to more appropriate places.

While the comment is useful, it violates general style of this example. I suggest moving documentation to more appropriate places.
Author
Collaborator

example simplified, the long comment removed; explanation lives in the man page now

example simplified, the long comment removed; explanation lives in the man page now
jabber.developer marked this conversation as resolved
@@ -1888,2 +1896,3 @@
CMD_ARGS(
{ "on|off", "Enable or disable showing chat history." })
{ "on|off", "Enable or disable showing chat history." },
{ "switch sqlite|flatfile", "Switch the active database backend at runtime." },

Does the switch automatically export/import? It is unclear from documentation.

Does the switch automatically export/import? It is unclear from documentation.
Author
Collaborator

documentation-only clarification, queued. /history switch only swaps the active backend; data is not migrated unless the user runs /history export and /history import explicitly.

documentation-only clarification, queued. /history switch only swaps the active backend; data is not migrated unless the user runs /history export and /history import explicitly.
jabber.developer marked this conversation as resolved
@@ -1889,1 +1897,3 @@
{ "on|off", "Enable or disable showing chat history." })
{ "on|off", "Enable or disable showing chat history." },
{ "switch sqlite|flatfile", "Switch the active database backend at runtime." },
{ "verify [<jid>]", "Verify integrity of message history. Optionally specify a JID to check only one contact." },

How does it align with flatfile manual editability?

How does it align with flatfile manual editability?
Author
Collaborator

needs a doc paragraph explaining that ff_verify_integrity flags timestamp drift, dup IDs, broken LMC refs etc. so manual edits remain auditable. Doc-only follow-up

needs a doc paragraph explaining that ff_verify_integrity flags timestamp drift, dup IDs, broken LMC refs etc. so manual edits remain auditable. Doc-only follow-up
jabber.developer marked this conversation as resolved
@@ -2719,3 +2731,3 @@
CMD_TAG_DISCOVERY)
CMD_SYN(
"/privacy logging on|redact|off",
"/privacy logging on|redact|off|flatfile",

on|flatfile|redact|off format would look more consistent (from most logging to least logging).

Additionally, necessity for the redact option needs to be discussed. Though, as far as I remember, it is necessary for OMEMO/groupchat XEPs to properly sync the chats.

`on|flatfile|redact|off` format would look more consistent (from most logging to least logging). Additionally, necessity for the `redact` option needs to be discussed. Though, as far as I remember, it is necessary for OMEMO/groupchat XEPs to properly sync the chats.
Author
Collaborator

design discussion. Listing order is cosmetic; the redact mode question is a separate decision. No code change in this PR

design discussion. Listing order is cosmetic; the redact mode question is a separate decision. No code change in this PR
jabber.developer marked this conversation as resolved
@@ -6687,2 +6687,4 @@
cons_show("Messages are going to be redacted.");
prefs_set_string(PREF_DBLOG, arg);
} else if (g_strcmp0(arg, "flatfile") == 0) {
cons_show("Using flat-file backend for message logging. Takes effect on next connection.");

It might be beneficial to also output flatfile location to the user.

It might be beneficial to also output flatfile location to the user.
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -6733,0 +6738,4 @@
if (g_strcmp0(args[0], "switch") == 0) {
jabber_conn_status_t conn_status = connection_get_status();
if (conn_status != JABBER_CONNECTED) {
cons_show_error("You must be connected to switch database backend.");

It future, this requirement should be removed as it doesn't make much sense. Offline capabilities are really lacking. But I see why it exists (local SQLite is getting disconnected on disconnect).

Feel free to click "resolve" on this comment.

It future, this requirement should be removed as it doesn't make much sense. Offline capabilities are really lacking. But I see why it exists (local SQLite is getting disconnected on disconnect). Feel free to click "resolve" on this comment.
jabber.developer2 marked this conversation as resolved
@@ -6733,0 +6747,4 @@
return TRUE;
}
if (g_strcmp0(args[1], "sqlite") != 0 && g_strcmp0(args[1], "flatfile") != 0) {

Nice nesting: everything is flat. Easy to read, easy to maintain.

Resolveable.

Nice nesting: everything is flat. Easy to read, easy to maintain. Resolveable.
jabber.developer2 marked this conversation as resolved
@@ -6733,0 +6795,4 @@
}
if (issue->line > 0) {
if (issue->level == INTEGRITY_ERROR) {
cons_show_error("[%s] %s:%d — %s", level_str, issue->file, issue->line, issue->message);

nit: It might be more readable if it's in the separate method. I suggest extraction of the error printing in a separate method.

nit: It might be more readable if it's in the separate method. I suggest extraction of the error printing in a separate method.
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
src/database.c Outdated
@@ -252,0 +75,4 @@
active_db_backend = db_backend_sqlite();
log_info("Using SQLite database backend");
#else
log_info("SQLite not compiled in, falling back to flat-file backend");

Shouldn't it be a warning, since it's not an expected state?

Shouldn't it be a warning, since it's not an expected state?
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -262,0 +105,4 @@
// Update preference
prefs_set_string(PREF_DBLOG, new_backend);
prefs_save();

Typically, prefs are not autosaved and need manual saving via /save command. This place creates an exception, which needs to be discussed (e.g., later we might add /autosave command).

Typically, prefs are not autosaved and need manual saving via `/save` command. This place creates an exception, which needs to be discussed (e.g., later we might add `/autosave` command).
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
src/database.c Outdated
@@ -269,2 +131,2 @@
} else {
_add_to_db(message, NULL, message->from_jid, connection_get_jid());
if (active_db_backend && active_db_backend->add_incoming) {
active_db_backend->add_incoming(message);

here and in other places: if the method does not exists, then it just passes. I think that we need to log this invalid state.

here and in other places: if the method does not exists, then it just passes. I think that we need to log this invalid state.
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +2,4 @@
* database_export.c
* vim: expandtab:ts=4:sts=4:sw=4
*
* Copyright (C) 2026 Profanity Contributors

We are not affiliated with Profanity.

Use the following lines for copyright notice:

// SPDX-License-Identifier: GPL-3.0-or-later
//
// This file is part of CProof.
// See LICENSE for the full GPLv3 text and the special OpenSSL linking exception.
We are not affiliated with Profanity. Use the following lines for copyright notice: ```c // SPDX-License-Identifier: GPL-3.0-or-later // // This file is part of CProof. // See LICENSE for the full GPLv3 text and the special OpenSSL linking exception. ```
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +106,4 @@
GDir* dir = g_dir_open(base_dir, 0, &err);
if (!dir) {
if (err) {
log_debug("export: cannot open flatlog dir %s: %s", base_dir, err->message);

the issue doesn't appear to be pure debug level, but rather warn at the very least

the issue doesn't appear to be pure `debug` level, but rather `warn` at the very least
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +109,4 @@
log_debug("export: cannot open flatlog dir %s: %s", base_dir, err->message);
g_error_free(err);
}
return NULL;

will fail silently if no err is present.

will fail silently if no `err` is present.
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +146,4 @@
while (1) {
gboolean truncated = FALSE;
char* buf = ff_readline(fp, &truncated);

why not auto_char instead of free'ing?

why not `auto_char` instead of `free`'ing?
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +334,4 @@
char* key = _make_dedup_key(sid, ts, from_jid, body);
if (g_hash_table_contains(seen_keys, key)) {
g_free(key);

why g_free if key is char? Normally, we do this:
g_free for gchar
free for char

why `g_free` if `key` is `char`? Normally, we do this: `g_free` for `gchar` `free` for `char`
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +387,4 @@
if (rename(tmp_path, log_path) != 0) {
log_error("export: rename %s -> %s failed: %s", tmp_path, log_path, strerror(errno));
unlink(tmp_path);
}

We can't just print Exported %s... to user after this case. It's a error and should be handled as such.

We can't just print `Exported %s`... to user after this case. It's a error and should be handled as such.
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +401,4 @@
g_slist_free_full(contacts, g_free);
return total_exported;
}

nit: This method is quite fat. In some sense, it's needed for coherency, but I might've overlooked some potential issues with early returns. If possible, I suggest extracting at least body of the cycle.

nit: This method is quite fat. In some sense, it's needed for coherency, but I might've overlooked some potential issues with early returns. If possible, I suggest extracting at least body of the cycle.
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +480,4 @@
if (!pl || !pl->timestamp)
continue;
auto_gchar gchar* ts = g_date_time_format_iso8601(pl->timestamp);

nit: this conversion is done twice (see l463: auto_gchar gchar* ts = msg->timestamp ? g_date_time_format_iso8601(msg->timestamp) : g_strdup("");).

nit: this conversion is done twice (see l463: ` auto_gchar gchar* ts = msg->timestamp ? g_date_time_format_iso8601(msg->timestamp) : g_strdup("");`).
Author
Collaborator

Postponed — investigated: the two ts conversions on the SAME message happen in the import path (once for dedup-key building, once inside _add_to_db when serializing for SQL). Removing the duplicate would require an _add_to_db API change to accept a pre-formatted ts. Microsecond per row × 1M = ~1 s saving — accepted as deferred until that signature changes for other reasons.

Postponed — investigated: the two ts conversions on the SAME message happen in the import path (once for dedup-key building, once inside _add_to_db when serializing for SQL). Removing the duplicate would require an _add_to_db API change to accept a pre-formatted ts. Microsecond per row × 1M = ~1 s saving — accepted as deferred until that signature changes for other reasons.
jabber.developer marked this conversation as resolved
@@ -0,0 +520,4 @@
}
}
sqlite_be->add_incoming(msg);

note to check: do we sort by timestamp when we fetch from the sqlite? Or is it default sorting by id which presumes that all messages are in order?

note to check: do we sort by timestamp when we fetch from the sqlite? Or is it default sorting by id which presumes that all messages are in order?
Author
Collaborator

Verified — no change needed. db_sqlite_get_all_chat already has "ORDER BY A.timestamp ASC" (database_sqlite.c:916). Documented in 555faaa23 commit body.

Verified — no change needed. db_sqlite_get_all_chat already has "ORDER BY A.timestamp ASC" (database_sqlite.c:916). Documented in 555faaa23 commit body.
jabber.developer marked this conversation as resolved
@@ -0,0 +531,4 @@
}
contact_imported++;
if (contact_imported % 500 == 0) {

magic number

magic number
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +546,4 @@
cons_show("Imported %s: %d new, %d skipped (already in SQLite)",
contact, contact_imported, contact_skipped);
total_imported += contact_imported;
total_skipped += contact_skipped;

total_skipped is unused variable

`total_skipped` is unused variable
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +2,4 @@
* database_flatfile.c
* vim: expandtab:ts=4:sts=4:sw=4
*
* Copyright (C) 2026 Profanity Contributors

Same issue as earlier with copyright. We are not affiliated with Profanity.

Use the following lines for copyright notice:

// SPDX-License-Identifier: GPL-3.0-or-later
//
// This file is part of CProof.
// See LICENSE for the full GPLv3 text and the special OpenSSL linking exception.

Same issue with other files.

Same issue as earlier with copyright. We are not affiliated with Profanity. Use the following lines for copyright notice: ```c // SPDX-License-Identifier: GPL-3.0-or-later // // This file is part of CProof. // See LICENSE for the full GPLv3 text and the special OpenSSL linking exception. ``` Same issue with other files.
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +86,4 @@
g_free(state);
}
// Lightweight ID extraction from a raw log line for the dedup/LMC cache.

general note: LMC is an optional feature. If it's disabled and the log is refetched, it should not be applied.

general note: LMC is an optional feature. If it's disabled and the log is refetched, it should not be applied.
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +115,4 @@
} else if (g_str_has_prefix(parts[i], "aid:") && !archive_id) {
archive_id = ff_unescape_meta_value(parts[i] + 4);
}
}

nit: I suggest this version for readability (avoid magic number of 3 and 4).

for (int i = 0; parts[i]; i++) {
    if (g_str_has_prefix(parts[i], "id:") && !stanza_id) {
        stanza_id = ff_unescape_meta_value(parts[i] + strlen("id:"));
    } else if (g_str_has_prefix(parts[i], "aid:") && !archive_id) {
        archive_id = ff_unescape_meta_value(parts[i] + strlen("aid:"));
    }
}

Ideally, we can also move "id:" and "aid:" into separate constants. It would also increase robustness in case of prefix changes.

nit: I suggest this version for readability (avoid magic number of 3 and 4). ```c for (int i = 0; parts[i]; i++) { if (g_str_has_prefix(parts[i], "id:") && !stanza_id) { stanza_id = ff_unescape_meta_value(parts[i] + strlen("id:")); } else if (g_str_has_prefix(parts[i], "aid:") && !archive_id) { archive_id = ff_unescape_meta_value(parts[i] + strlen("aid:")); } } ``` Ideally, we can also move "id:" and "aid:" into separate constants. It would also increase robustness in case of prefix changes.
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +121,4 @@
// Extract from_jid (needed for stanza_senders map): after "] " before "/" or ": "
char* from_jid = NULL;
if (stanza_id && *(close + 1) == ' ') {

what if *(close + 1) overflows?

what if `*(close + 1)` overflows?
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +122,4 @@
// Extract from_jid (needed for stanza_senders map): after "] " before "/" or ": "
char* from_jid = NULL;
if (stanza_id && *(close + 1) == ' ') {
const char* sender_start = close + 2;

again no overflow check

again no overflow check
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +126,4 @@
const char* colonspace = ff_find_unescaped_colonspace(sender_start);
if (colonspace) {
const char* jid_end = colonspace;
for (const char* p = sender_start; p < colonspace; p++) {

Instead of loop, we can do something like that:
g_utf8_strchr(sender_start, colonspace, '/')

Instead of loop, we can do something like that: `g_utf8_strchr(sender_start, colonspace, '/')`
jabber.developer marked this conversation as resolved
@@ -0,0 +160,4 @@
int c1 = fgetc(fp);
int c2 = fgetc(fp);
int c3 = fgetc(fp);
if (c1 == 0xEF && c2 == 0xBB && c3 == 0xBF) {

Please, extract bom-check to a different method. it is violates DRY and SRP. database_flatfile_validate.c:123

Please, extract bom-check to a different method. it is violates DRY and SRP. `database_flatfile_validate.c:123`
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +196,4 @@
// Extract IDs for O(1) dedup/LMC cache
_ff_cache_line_ids(state, buf);
if (msg_count % FF_INDEX_STEP == 0) {

please add comment explaining purpose

please add comment explaining purpose
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +201,4 @@
if (space) {
char* ts_str = g_strndup(buf, space - buf);
GDateTime* dt = g_date_time_new_from_iso8601(ts_str, NULL);
if (dt) {

Are !dt and !space cases ok to be ignored?

Are `!dt` and `!space` cases ok to be ignored?
Author
Collaborator

Postponed — discussion. Current behaviour is "skip silently" (lines outside index step are not indexed); this is by design. No specific change requested.

Postponed — discussion. Current behaviour is "skip silently" (lines outside index step are not indexed); this is by design. No specific change requested.
jabber.developer marked this conversation as resolved
@@ -0,0 +203,4 @@
GDateTime* dt = g_date_time_new_from_iso8601(ts_str, NULL);
if (dt) {
if (state->n_entries >= state->cap_entries) {
state->cap_entries = state->cap_entries ? state->cap_entries * 2 : 64;

please, consider reduction of nesting

please, consider reduction of nesting
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +283,4 @@
// Extract IDs for O(1) dedup/LMC cache
_ff_cache_line_ids(state, buf);
if (new_count % FF_INDEX_STEP == 0) {

the logic seemingly mirrors the one from _ff_state_build, creating repetition. I suggest extracting it to a different method to keep things DRYer and more maintainable

the logic seemingly mirrors the one from `_ff_state_build`, creating repetition. I suggest extracting it to a different method to keep things DRYer and more maintainable
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +405,4 @@
{
auto_gchar gchar* pref_dblog = prefs_get_string(PREF_DBLOG);
if (g_strcmp0(pref_dblog, "off") == 0) {

note to check: is it case sensitive/get forced lowercase when set by user?

note to check: is it case sensitive/get forced lowercase when set by user?

Checked cmd_funcs.c:6676:


        if (g_strcmp0(arg, "on") == 0) {
            cons_show("Logging enabled.");
            prefs_set_string(PREF_DBLOG, arg);
            prefs_set_boolean(PREF_CHLOG, TRUE);
            prefs_set_boolean(PREF_HISTORY, TRUE);
        } else if (g_strcmp0(arg, "off") == 0) {
            cons_show("Logging disabled.");
            prefs_set_string(PREF_DBLOG, arg);
            prefs_set_boolean(PREF_CHLOG, FALSE);
            prefs_set_boolean(PREF_HISTORY, FALSE);
        }

resolved

Checked cmd_funcs.c:6676: ```c if (g_strcmp0(arg, "on") == 0) { cons_show("Logging enabled."); prefs_set_string(PREF_DBLOG, arg); prefs_set_boolean(PREF_CHLOG, TRUE); prefs_set_boolean(PREF_HISTORY, TRUE); } else if (g_strcmp0(arg, "off") == 0) { cons_show("Logging disabled."); prefs_set_string(PREF_DBLOG, arg); prefs_set_boolean(PREF_CHLOG, FALSE); prefs_set_boolean(PREF_HISTORY, FALSE); } ``` resolved
jabber.developer marked this conversation as resolved
@@ -0,0 +448,4 @@
// Open the file with O_NOFOLLOW to prevent symlink attacks,
// and O_APPEND for safe concurrent appends.
// Try O_CREAT|O_EXCL first to detect new files atomically (no TOCTOU).
int fd = open(log_path, O_WRONLY | O_APPEND | O_CREAT | O_EXCL | O_NOFOLLOW,

O_APPEND won't execute since O_EXCL would throw an error if file already exists.

`O_APPEND` won't execute since `O_EXCL` would throw an error if file already exists.
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +591,4 @@
return;
const Jid* to_jid = message->to_jid ? message->to_jid : connection_get_jid();
const char* type = ff_get_message_type_str(message->type);

note: is it null safe?

note: is it null safe?
Author
Collaborator

Verified — null-safe by construction. Input is a prof_msg_type_t enum (cannot be NULL), and every switch arm returns a string literal; the catch-all return "chat" covers any future enum addition. Callers' g_strdup(...) is therefore always safe.

Verified — null-safe by construction. Input is a prof_msg_type_t enum (cannot be NULL), and every switch arm returns a string literal; the catch-all `return "chat"` covers any future enum addition. Callers' g_strdup(...) is therefore always safe.
jabber.developer marked this conversation as resolved
@@ -0,0 +604,4 @@
// MAM dedup: skip if this stanza-id already exists in the log (XEP-0313 replay)
if (message->stanzaid && !message->is_mam) {
if (_ff_has_archive_id(contact, message->stanzaid)) {
log_error("flatfile: duplicate stanza-id '%s' from %s, skipping",

stanza-id can be non-unique on certain clients (e.g., older versions of Profanity, as well as Pidgin, Adium and other libpurple-based XMPP clients which use(d) incremental stanza IDs). It would be more correct to ignore it.

See https://github.com/profanity-im/profanity/issues/1899

However, message->stanzaid links to the archive ID, which might be logical in this case.

`stanza-id` can be non-unique on certain clients (e.g., older versions of Profanity, as well as Pidgin, Adium and other `libpurple`-based XMPP clients which use(d) incremental stanza IDs). It would be more correct to ignore it. See https://github.com/profanity-im/profanity/issues/1899 However, `message->stanzaid` links to the archive ID, which might be logical in this case.

This lines copies functionality of original database.c and shouldn't cause issues.

This lines copies functionality of original `database.c` and shouldn't cause issues.
jabber.developer marked this conversation as resolved
@@ -0,0 +666,4 @@
// Apply LMC: for messages with corrects:X, find original and replace its text
static void
_ff_apply_lmc(GSList* parsed_lines, GSList** result)

we should be careful with LMC, since it can be disabled by user, so this part needs further review and discussion.

we should be careful with LMC, since it can be disabled by user, so this part needs further review and discussion.
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +2,4 @@
* database_flatfile.h
* vim: expandtab:ts=4:sts=4:sw=4
*
* Copyright (C) 2026 Profanity Contributors

Same issue as earlier with copyright. We are not affiliated with Profanity.

Use the following lines for copyright notice:

// SPDX-License-Identifier: GPL-3.0-or-later
//
// This file is part of CProof.
// See LICENSE for the full GPLv3 text and the special OpenSSL linking exception.
Same issue as earlier with copyright. We are not affiliated with Profanity. Use the following lines for copyright notice: ```c // SPDX-License-Identifier: GPL-3.0-or-later // // This file is part of CProof. // See LICENSE for the full GPLv3 text and the special OpenSSL linking exception. ```
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +120,4 @@
int c1 = fgetc(fp);
int c2 = fgetc(fp);
int c3 = fgetc(fp);
if (c1 == 0xEF && c2 == 0xBB && c3 == 0xBF) {

Please, extract bom-check to a different method. it is violates DRY and SRP. Repeated in database_flatfile.c:163

Please, extract bom-check to a different method. it is violates DRY and SRP. Repeated in `database_flatfile.c:163`
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
jabber.developer requested changes 2026-03-31 15:46:46 +00:00
@@ -0,0 +114,4 @@
// This prevents a malicious federated JID like "../../../tmp/pwned" from
// escaping the log directory tree.
char*
ff_jid_to_dir(const char* jid)

I don't understand the need for gstring and step1. I propose a simpler solution that might be more efficient, but I might've missed some intricacies:

char* ff_jid_to_dir(const char* jid)
{
    if (!jid || jid[0] == '\0')
        return NULL;
    
    // Replace '@' with "_at_"
    char* result = str_replace(jid, "@", "_at_");
    if (!result)
        return NULL;
    
    // Replace '/', '\\' with '_'
    for (char* p = result; *p; p++) {
        if (*p == '/' || *p == '\\') {
            *p = '_';
        }
    }
    
    // Collapse ".." to "__"
    char* dotdot;
    while ((dotdot = strstr(result, "..")) != NULL) {
        dotdot[0] = '_';
        dotdot[1] = '_';
    }
    
    // Reject empty result
    if (result[0] == '\0') {
        free(result);
        return NULL;
    }
    
    return result;
}
I don't understand the need for `gstring` and `step1`. I propose a simpler solution that might be more efficient, but I might've missed some intricacies: ```c char* ff_jid_to_dir(const char* jid) { if (!jid || jid[0] == '\0') return NULL; // Replace '@' with "_at_" char* result = str_replace(jid, "@", "_at_"); if (!result) return NULL; // Replace '/', '\\' with '_' for (char* p = result; *p; p++) { if (*p == '/' || *p == '\\') { *p = '_'; } } // Collapse ".." to "__" char* dotdot; while ((dotdot = strstr(result, "..")) != NULL) { dotdot[0] = '_'; dotdot[1] = '_'; } // Reject empty result if (result[0] == '\0') { free(result); return NULL; } return result; } ```
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
jabber.developer added the
Priority
High
2
label 2026-04-24 14:29:42 +00:00
jabber.developer requested changes 2026-04-26 22:10:19 +00:00
jabber.developer left a comment
Owner

Generally, it's good and well-written. Some parts require minor polish + merge to upstream. Overall, it's a solid job.

Generally, it's good and well-written. Some parts require minor polish + merge to upstream. Overall, it's a solid job.
@@ -0,0 +424,4 @@
// Determine which JID to use for the log file path (the "other" party)
const char* contact = NULL;
const Jid* myjid = connection_get_jid();
if (myjid && myjid->barejid && g_strcmp0(from_barejid, myjid->barejid) == 0) {

nit: for readability, maybe we can do something like that?

gboolean is_incoming = myjid && myjid->barejid && g_strcmp0(from_barejid, myjid->barejid) == 0;
contact = is_incoming ? to_barejid : from_barejid;
nit: for readability, maybe we can do something like that? ```c gboolean is_incoming = myjid && myjid->barejid && g_strcmp0(from_barejid, myjid->barejid) == 0; contact = is_incoming ? to_barejid : from_barejid; ```
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +187,4 @@
if (g_file_test(path, G_FILE_TEST_IS_DIR)) {
// Verify it's not a symlink
struct stat st;
if (g_lstat(path, &st) == 0 && S_ISLNK(st.st_mode)) {

why not use g_file_test in a similar fashion here?
g_file_test(path, G_FILE_TEST_IS_SYMLINK)

why not use `g_file_test` in a similar fashion here? `g_file_test(path, G_FILE_TEST_IS_SYMLINK)`
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +308,4 @@
return NULL;
GString* out = g_string_sized_new(strlen(val));
for (const char* p = val; *p; p++) {
if (*p == '\\' && *(p + 1)) {

early exit here would lower the nesting and potentially improve readability
something like:
if(!condition) {
g_string_append_c(out, *p);
continue;
}

early exit here would lower the nesting and potentially improve readability something like: if(!condition) { g_string_append_c(out, *p); continue; }
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +346,4 @@
// newline, or NULL on EOF. Sets *truncated=TRUE if the line had no trailing
// newline at EOF (partial write detection).
char*
ff_readline(FILE* fp, gboolean* truncated)

note for myself: come back to it and rereview

note for myself: come back to it and rereview
jabber.developer marked this conversation as resolved
@@ -0,0 +371,4 @@
*truncated = FALSE;
// Return empty string so caller's loop continues (parse will reject it).
// Use malloc() so callers can always use free() consistently.
char* empty = malloc(1);

nit: Why not strdup("")?

nit: Why not `strdup("")`?
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +459,4 @@
}
// Escape message body to prevent log injection
char* safe_msg = ff_escape_message(message_text);

why not auto_char?

why not auto_char?
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +470,4 @@
size_t written = fwrite(full_line->str, 1, to_write, fp);
if (written != to_write) {
log_error("flatfile: partial write (%zu/%zu)", written, to_write);
}

I feel that it's overly complicated. Starting from the GString* full_line, everything could be done in nearly a one-liner:

int ret = fprintf(fp, "%s %s %s: %s\n",
                  timestamp, meta->str, sender->str, safe_msg);
if (ret < 0) {
    log_error("flatfile: fprintf failed");
}

I feel that it's overly complicated. Starting from the `GString* full_line`, everything could be done in nearly a one-liner: ```c int ret = fprintf(fp, "%s %s %s: %s\n", timestamp, meta->str, sender->str, safe_msg); if (ret < 0) { log_error("flatfile: fprintf failed"); } ```
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +646,4 @@
// Parse metadata section [...]
const char* bracket_end = ff_find_unescaped_char(bracket_start + 1, ']');
if (bracket_end) {

please make early return here to significantly reduce nesting

please make early return here to significantly reduce nesting
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +652,4 @@
// Split by unescaped '|'
char** parts = ff_split_meta(meta_content);
if (parts) {
int i = 0;

it could be extracted to another method

it could be extracted to another method
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +38,4 @@
#include "database_flatfile.h"
GSList*
ff_verify_integrity(const gchar* const contact_barejid)

It's a really big function that should ideally be split for readability. Additionally, I'd like to see at least a short comment describing what's going on. Comment with higher level of abstraction in .h file and comment with more details in .c file so people that are reading header can understand how to use the function, while people that are reading (and likely prepare to edit) source code understand internals.

It's a really big function that should ideally be split for readability. Additionally, I'd like to see at least a short comment describing what's going on. Comment with higher level of abstraction in `.h` file and comment with more details in `.c` file so people that are reading header can understand how to use the function, while people that are reading (and likely prepare to edit) source code understand internals.
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +43,4 @@
GSList* issues = NULL;
if (!g_flatfile_account_jid) {
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));

Here and everywhere else, we can use new style for consistency with g_new0

Here and everywhere else, we can use new style for consistency with `g_new0`
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +95,4 @@
auto_gchar gchar* filepath = g_strdup_printf("%s/history.log", cdir_path);
if (!g_file_test(filepath, G_FILE_TEST_EXISTS))
continue;

I am not sure if it's ok to just silently continue in this place. It deserves at the very least debug log.

I am not sure if it's ok to just silently `continue` in this place. It deserves at the very least debug log.
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +134,4 @@
char* buf = NULL;
int lineno = 0;
GDateTime* prev_ts = NULL;
GHashTable* seen_ids = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);

I am not sure if the overlap between stanza_ids and archive_ids is a valid approach. We might want to separate them in two distinct tables.

I am not sure if the overlap between `stanza_ids` and `archive_ids` is a valid approach. We might want to separate them in two distinct tables.
Author
Collaborator

Postponed, architecture only question

Postponed, architecture only question
jabber.developer marked this conversation as resolved
@@ -0,0 +264,4 @@
int b1 = fgetc(fp);
int b2 = fgetc(fp);
int b3 = fgetc(fp);
if (!(b1 == 0xEF && b2 == 0xBB && b3 == 0xBF))

Please, extract bom-check to a different method.

Please, extract bom-check to a different method.
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +1,966 @@
/*

It seemingly mirrors functionality of the original database.c + adds integrity verification.

The main request here is to add comments for readability and comprehension. Otherwise, if the functionality mirrors previous one, so there shouldn't be an issue.

NOTE: we'll need to add migration and new changes after upstream merge.

It seemingly mirrors functionality of the original `database.c` + adds integrity verification. The main request here is to add comments for readability and comprehension. Otherwise, if the functionality mirrors previous one, so there shouldn't be an issue. NOTE: we'll need to add migration and new changes after upstream merge.
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +128,4 @@
auto_char char* filename = _get_db_filename(account);
if (!filename) {
sqlite3_shutdown();

We should add error logging here.

We should add error logging here.
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +144,4 @@
int db_version = _get_db_version();
if (db_version == latest_version) {
return TRUE;

We might want to also check integrity of the tables (whether they exist + maybe even their columns). But it would introduce further complexity and should be a subject of a separate issue.

We might want to also check integrity of the tables (whether they exist + maybe even their columns). But it would introduce further complexity and should be a subject of a separate issue.
Author
Collaborator

Postponed

Postponed
jabber.developer marked this conversation as resolved
@@ -0,0 +147,4 @@
return TRUE;
}
const char* query = "CREATE TABLE IF NOT EXISTS `ChatLogs` ("

Here and in other places, can we return necessary documentation detailing DB fields, decisions, and other useful points?

Here and in other places, can we return necessary documentation detailing DB fields, decisions, and other useful points?
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +7,4 @@
*
* These tests only exercise the public log_database API through normal
* profanity UI operations, so they work with any storage backend.
*/

Considerations: we need to test for the messages from the same user, but with different resources.

Considerations: we need to test for the messages from the same user, but with different resources.
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +1,721 @@
/*

Add invalid date tests

Add invalid date tests
Author
Collaborator

Corrected

Corrected
@@ -0,0 +1104,4 @@
int corrections_found = 0;
while (1) {
char* buf = ff_readline(fp, NULL);

Maybe auto_char, if common.h import is allowed?

Maybe `auto_char`, if `common.h` import is allowed?
Author
Collaborator

Corrected

Corrected
jabber.developer marked this conversation as resolved
@@ -0,0 +1107,4 @@
char* buf = ff_readline(fp, NULL);
if (!buf)
break;
if (buf[0] == '#' || buf[0] == '\0') {

Here and everywhere else: no need for this check, as it will already be done within the ff_parse_line.

Here and everywhere else: no need for this check, as it will already be done within the `ff_parse_line`.
Author
Collaborator

Corrected

Corrected
jabber.developer2 added 5 commits 2026-04-30 14:14:54 +00:00
Blockers:
- SPDX/CProof copyright headers (7 files)
- stanza-id non-uniqueness: warn-only, don't skip (matches SQLite)
- fopen → open(O_EXCL|O_NOFOLLOW, 0600) + fdopen in export

Medium:
- log_info → log_warning for SQLite fallback
- remove prefs_save() auto-save from backend switch
- vtable null → log_warning in 4 dispatch functions
- log_debug → log_warning + null error handling in export
- rename failure → cons_show_error to user
- remove unused total_skipped variable
- LMC respects PREF_CORRECTION_ALLOW

Low nits:
- man page .SS fix + jabber.space URL
- profrc.example simplified
- show flatfile path on /logging flatfile
- EXPORT_PROGRESS_INTERVAL constant (magic 500)
- FF_META_PREFIX_ID/AID constants + FF_INDEX_STEP comment
- buffer overflow guard on close[1]/close[2]
- for-loop → memchr for JID resource scan
- BOM check extracted to ff_skip_bom() (DRY)
- index building extracted to _ff_maybe_index_line()
- O_APPEND+O_EXCL comment rewritten
- Makefile.am spaces → tabs
- extract _show_integrity_issues() from cmd_history verify block
- extract _ff_scan_lines() shared by _ff_state_build and _ff_state_extend
- extract _export_one_contact() from log_database_export_to_flatfile loop
Parser (src/database_flatfile_parser.c):
- ff_jid_to_dir: drop GString+step1, in-place mutate str_replace result
- ff_ensure_dir: g_file_test(G_FILE_TEST_IS_SYMLINK) instead of g_lstat
- ff_unescape_*: early-continue for non-escape branch (less nesting)
- ff_readline: strdup("") instead of malloc(1)+'\0' for skip-line return
- ff_write_line: auto_char safe_msg + single fprintf (drop GString full_line)
- ff_parse_line: early return on missing closing bracket;
  extract metadata-parts loop into _ff_parse_meta_parts helper

Verify (src/database_flatfile_verify.c):
- Split monolithic ff_verify_integrity into _collect_contact_dirs,
  _check_permissions, _first_pass, _lmc_pass, _verify_contact_dir
- _issue_new constructor; g_new0 throughout
- Separate seen_stanza_ids and seen_archive_ids tables (no cross-kind
  duplicate confusion)
- ff_skip_bom in second pass (drop manual fgetc x3)
- log_debug when skipping a contact dir without history.log
- Doc comment on ff_verify_integrity in header

SQLite (src/database_sqlite.c):
- Doc block on ChatLogs schema (replaces_db_id <-> replaced_by_db_id
  invariant maintained by AFTER INSERT trigger)
- log_error when _get_db_filename returns NULL during init

Flatfile dispatch (src/database_flatfile.c):
- Replace if/else with is_outgoing ternary for log-path contact pick

Tests:
- 6 new invalid-date parser tests: empty / partial ISO / garbage /
  impossible calendar / negative year / far future timestamp
- New functional test message_db_history_multi_resource verifying
  same-JID-different-resource history rehydration preserves resources
- test_database_stress.c LMC chain loop: auto_char buf, drop redundant
  comment/empty pre-filter (ff_parse_line handles those)
End-to-end performance and correctness harness for the flat-file +
SQLite database backends. Lives in tests/bench/, built only on
demand (`make bench`); not part of `make check`.

Components

  gen_history (P1)
    Deterministic corpus generator. Knobs: lines, contacts, years,
    seed, stanza-id mode (uuid/libpurple/conversations/mixed), LMC
    rate, MAM-OOO rate, resources/contact, length profile
    (short/mixed/long/extreme). Emits the canonical
    flatlog/<account>/<contact>/history.log layout used by
    ff_verify_integrity. ~340 LOC.

  bench_runner (P1, P2.5)
    S1 cold tail-access via sparse index
    S2 warm tail-access (page cache hot)
    S3 deep pagination (1000 binary-search lookups)
    S4 first-time index build (cold file -> ff_state_ensure_fresh)
    S5 incremental extend (asserts no full rebuild path)
    S6 real ff_verify_integrity over the contact tree
    Reports total/err/warn/info issue counts in the CSV note.

  bench_long_messages (P2)
    L1-L14 long-message stress: 1KB up to 9.9MB bodies, plus
    oversized line rejection (10MB+1 -> ff_readline returns ""),
    embedded-newline / pipe / emoji body patterns, full parse on
    100x1MB, 1000x100KB sustained append.

  bench_failure_modes (P3)
    F1-F15 failure-injection: truncated last line, mid-file CRLF,
    mid-file BOM, LMC cycle, LMC depth>FF_MAX_LMC_DEPTH, manual
    ': ' in resource, RTL/ZWSP, Latin-1 byte, empty body,
    mtime/inode flip, empty file. Each test asserts expected issue
    levels and reports PASS/FAIL.

  bench_export_import (P5)
    Links real database_export.c + database_sqlite.c + database.c
    and drives log_database_export_to_flatfile /
    log_database_import_from_flatfile under load.
    Subcommands: seed, export, import, roundtrip, verify.
    S7a/b export, S8a/b import, S8e roundtrip with full byte-by-byte
    content diff of every row in (from_jid, to_jid, message,
    timestamp, type, stanza_id, archive_id, encryption, replace_id).

Make targets

  bench-quick / bench / bench-full
  bench-longmsg, bench-failure
  bench-multicontact, bench-lmc, bench-ooo
  bench-export, bench-import, bench-roundtrip
  bench-pipeline, bench-pipeline-max (1M rows)
  bench-compare, bench-update-baseline

Volume controls: BENCH_VOLUME (small/medium/max), BENCH_PIPE_ROWS,
BENCH_PIPE_ROWS_MAX, BENCH_DATA_DIR, BENCH_CSV.

Baseline + regression checking (P4)

  tests/bench/baseline.csv       51 rows: S1-S6 x {small,lmc,ooo}
                                 + L1-L14 + F1-F15 (11 of 15) +
                                 S7/S8 x {pipe100k, pipe1M}.
  compare_baseline.py            median over duplicate rows;
                                 exits 1 on any (scenario, volume)
                                 slowdown >= threshold (default 25%).

Verified at scale

  bench-pipeline-max: 1,000,000 rows, full content diff
    seed   17 s, export 304 s, import 31 s, idempotent re-import 10 s,
    diff 2.7 s -- mismatches=0.

Findings surfaced by the harness

  Export scales super-linearly: 4 s @ 100k -> 304 s @ 1M (76x for
    10x rows). Cause: g_slist_sort on the merged list + per-row
    ProfMessage/ff_parsed_line_t allocations. RSS peaks at 1.4 GB
    on 1M. Worth a follow-up.
  Export progress reporting only fires during the write phase --
    the merge+sort phase (~95% of wall time at 1M) is silent.
  /history export and /history import are blocking on the main
    UI thread; profanity is frozen for the duration (~5 min @ 1M).
  ff_readline sets *truncated=TRUE on partial-write tail but
    ff_verify_integrity does not surface this -- partial writes
    go unflagged (failure-injection F1).
  Parser silently truncates body at first unescaped ': ' if a
    resource was manually edited to contain it (F9).
  In gen_history (caught by the bench's own real-verify pass):
    g_strndup mid-codepoint truncation on UTF-8 bank strings -- fixed.

Linkage strategy

  database_flatfile.c + parser + verify + common.c are linked
  unconditionally. The export/import bench additionally links
  database.c + database_sqlite.c + database_export.c. bench_stubs.c
  provides minimal stubs for log_*, prefs_*, connection_get_jid,
  jid_create, files_*, message_*, ui hooks. integrity_issue_free
  is a weak symbol so it falls back to the real database.c
  implementation when that file is linked.
perf+harden(export): 25x faster export at 1M, mkstemp, full-body dedup
Some checks failed
CI Code / Check spelling (pull_request) Failing after 21s
CI Code / Linux (arch) (pull_request) Failing after 3m6s
CI Code / Check coding style (pull_request) Failing after 3m10s
CI Code / Code Coverage (pull_request) Successful in 2m49s
CI Code / Linux (debian) (pull_request) Successful in 4m26s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m38s
555faaa232
Address 6 of 7 high/medium audit findings on database_export.c.
End-to-end migration of 1M rows: ~5 min -> ~1 min.

  H1  cache g_slist_length(merged) out of the write loop — was O(n²)
      via 2000 list walks at 500-row progress interval, now O(1).
        S7a_export_cold @ 1M:  309 s -> 11.2 s  (-96 %)

  L1  splice existing flatfile lines into merged via ownership
      transfer instead of 12-strdup deep-copy + g_date_time_ref;
      free list nodes only, NULL out existing.
        S7b_export_dedup @ 1M: 304 s -> 13.3 s  (-96 %)
        RSS @ S7b 1M: 2.4 GB -> 1.9 GB

  H2  mkstemp() with random suffix instead of fixed
      "{log_path}.export.tmp". Eliminates the unlink-and-retry race
      where two concurrent exports could clobber each other's
      in-flight tmp files.

  M4  SHA-256 over full body (incremental g_checksum_update) in
      the dedup-key fallback. Previous body[:256] hash collided
      on common preambles (signatures, code blocks) — second
      occurrence silently dropped on import.

  M2  Refuse _ff_read_all_lines on files > 2 GB. Each line expands
      ~10x in heap on parse; without a cap we OOM-kill prof on
      large flatfiles. Surface a clear error instead.

  M5  Break the outer import loop on system-level INSERT failure.
      Was cascading "Import of X failed" through every remaining
      contact when the real issue was disk-full / sqlite locked.

Verified on bench-pipeline-max: 1M rows, full content diff,
mismatches=0. baseline.csv updated.

Deferred: M3 O_TMPFILE (platform-conditional + linkat dance;
mkstemp covers most of the same window) and M6 SIGINT cancellation
(needs event-loop architecture changes).
jabber.developer2 added 1 commit 2026-05-02 09:34:42 +00:00
style(export): gchar*/g_free consistency + auto_char in read loop
Some checks failed
CI Code / Check coding style (pull_request) Failing after 36s
CI Code / Code Coverage (pull_request) Successful in 2m33s
CI Code / Linux (debian) (pull_request) Successful in 4m25s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m34s
CI Code / Check spelling (pull_request) Failing after 2m36s
CI Code / Linux (arch) (pull_request) Successful in 12m32s
92f601d2ea
jabber.developer2 force-pushed feat/no-db-mode from 92f601d2ea to 1fe3808b23 2026-05-02 13:22:04 +00:00 Compare
jabber.developer approved these changes 2026-05-03 18:19:09 +00:00
jabber.developer left a comment
Owner

LGTM

LGTM
@@ -431,3 +173,1 @@
sqlite3_finalize(stmt);
return g_slist_length(*result) != 0 ? DB_RESPONSE_SUCCESS : DB_RESPONSE_EMPTY;
return DB_RESPONSE_ERROR;

where is logging? it can be hard to catch this place

where is logging? it can be hard to catch this place
jabber.developer marked this conversation as resolved
@@ -448,2 +181,3 @@
}
return NULL;
// Fallback: return an empty message with sane defaults
ProfMessage* msg = message_init();

nit: no logging again

nit: no logging again
jabber.developer marked this conversation as resolved
@@ -0,0 +63,4 @@
// bombs) — second occurrence was silently dropped on import. Hashing
// whole body is microseconds of extra work per row.
GChecksum* sum = g_checksum_new(G_CHECKSUM_SHA256);
g_checksum_update(sum, (const guchar*)(timestamp ? timestamp : ""), -1);

is it efficient? Might g_strdup_printf first be more efficient than updating checksum each time?

is it efficient? Might g_strdup_printf first be more efficient than updating checksum each time?
@@ -0,0 +199,4 @@
Jid*
jid_create(const gchar* const fulljid)
{
if (!fulljid)

not a stub

not a stub
jabber.developer marked this conversation as resolved

Bug Report: Flatfile Backend Page-Up Scroll Stuck at Top

Summary

The flatfile database backend's page-up (scroll-up) functionality was getting stuck in the middle of the conversation, returning WIN_SCROLL_REACHED_TOP prematurely and preventing users from loading older messages.

Root Cause

The flatfile backend uses a sparse index to efficiently locate messages by byte offset. When paginating up, it uses a cursor-based optimization that stores the byte offset of the oldest message from the previous batch, then reads from the beginning of the file up to that cursor position.

The bug occurred when:

  1. The cursor was set to the byte offset of the first index entry (near the file start)
  2. The !from_start backup logic tried to find index entries before this cursor position
  3. Since no entries existed before the first index entry, the backup logic fell back to the same position
  4. This created an empty byte range [X, X) — no bytes were read
  5. The database returned DB_RESPONSE_EMPTY, which incorrectly set the scroll state to WIN_SCROLL_REACHED_TOP
  6. Once in this state, all subsequent page-up calls skipped the database fetch entirely, permanently blocking older message loading

Technical Details

The cursor-based pagination path (line 753 in src/database_flatfile.c) was:

// Before fix
if (!start_time && end_time && state->cursor_offset >= 0) {
    read_to = state->cursor_offset;

When cursor_offset pointed to the first index entry's byte offset, the subsequent !from_start backup logic would:

  • Find entry_idx = 0 (the first entry at or after read_to)
  • Try to back up margin_entries positions, but entry_idx - margin_entries underflows to 0
  • Set read_from to the same offset as read_to
  • Result: empty range, no messages read

Fix

Added two additional conditions to the cursor-based pagination check:

// After fix
if (!start_time && end_time && state->cursor_offset >= 0 
    && state->n_entries > 0 
    && state->cursor_offset != state->entries[0].byte_offset) {

The new conditions:

  • state->n_entries > 0 — prevents accessing entries[0] when the index is empty
  • state->cursor_offset != state->entries[0].byte_offset — prevents cursor-based pagination when the cursor points to the first index entry, forcing fallback to the end_time bisect approach

Impact

  • Before fix: Page-up would get permanently stuck after the first scroll attempt, showing WIN_SCROLL_REACHED_TOP even when older messages existed
  • After fix: When cursor-based pagination would create an empty range, the code falls back to the end_time bisect approach, which correctly locates the byte range containing older messages

Files Modified

## Bug Report: Flatfile Backend Page-Up Scroll Stuck at Top ### Summary The flatfile database backend's page-up (scroll-up) functionality was getting stuck in the middle of the conversation, returning `WIN_SCROLL_REACHED_TOP` prematurely and preventing users from loading older messages. ### Root Cause The flatfile backend uses a **sparse index** to efficiently locate messages by byte offset. When paginating up, it uses a **cursor-based optimization** that stores the byte offset of the oldest message from the previous batch, then reads from the beginning of the file up to that cursor position. **The bug occurred when:** 1. The cursor was set to the byte offset of the **first index entry** (near the file start) 2. The `!from_start` backup logic tried to find index entries before this cursor position 3. Since no entries existed before the first index entry, the backup logic fell back to the same position 4. This created an **empty byte range** `[X, X)` — no bytes were read 5. The database returned `DB_RESPONSE_EMPTY`, which incorrectly set the scroll state to `WIN_SCROLL_REACHED_TOP` 6. Once in this state, all subsequent page-up calls skipped the database fetch entirely, permanently blocking older message loading ### Technical Details The cursor-based pagination path (line 753 in `src/database_flatfile.c`) was: ```c // Before fix if (!start_time && end_time && state->cursor_offset >= 0) { read_to = state->cursor_offset; ``` When `cursor_offset` pointed to the first index entry's byte offset, the subsequent `!from_start` backup logic would: - Find `entry_idx = 0` (the first entry at or after `read_to`) - Try to back up `margin_entries` positions, but `entry_idx - margin_entries` underflows to 0 - Set `read_from` to the same offset as `read_to` - Result: empty range, no messages read ### Fix Added two additional conditions to the cursor-based pagination check: ```c // After fix if (!start_time && end_time && state->cursor_offset >= 0 && state->n_entries > 0 && state->cursor_offset != state->entries[0].byte_offset) { ``` The new conditions: - `state->n_entries > 0` — prevents accessing `entries[0]` when the index is empty - `state->cursor_offset != state->entries[0].byte_offset` — prevents cursor-based pagination when the cursor points to the first index entry, forcing fallback to the `end_time` bisect approach ### Impact - **Before fix**: Page-up would get permanently stuck after the first scroll attempt, showing `WIN_SCROLL_REACHED_TOP` even when older messages existed - **After fix**: When cursor-based pagination would create an empty range, the code falls back to the `end_time` bisect approach, which correctly locates the byte range containing older messages ### Files Modified - [`src/database_flatfile.c:753`](src/database_flatfile.c:753) — Added safety conditions to cursor-based pagination logic
jabber.developer2 added 1 commit 2026-05-04 10:56:47 +00:00
fix(flatfile): page-up cursor must not stick on entries[0]
All checks were successful
CI Code / Check spelling (pull_request) Successful in 23s
CI Code / Check coding style (pull_request) Successful in 37s
CI Code / Code Coverage (pull_request) Successful in 2m39s
CI Code / Linux (debian) (pull_request) Successful in 4m51s
CI Code / Linux (ubuntu) (pull_request) Successful in 6m47s
CI Code / Linux (arch) (pull_request) Successful in 9m59s
a7da9e2add
When the cursor lands on entries[0].byte_offset, the !from_start
backup logic produces an empty [X, X) range, get_previous_chat
returns DB_RESPONSE_EMPTY, the UI sets WIN_SCROLL_REACHED_TOP and
never tries again — page-up is permanently stuck.

Guard the cursor optimisation with two conditions: index must be
non-empty AND cursor must not equal entries[0].byte_offset. When
either fails, fall through to the end_time bisect path which
correctly locates the byte range with older messages.

F16 regression test in bench_failure_modes:
  without guard: received=200 in 3 iters (premature EMPTY)
  with guard:    received=600 in 7 iters (truly reaches top)
jabber.developer2 added 1 commit 2026-05-04 12:09:58 +00:00
fix(flatfile): drop cursor optim + guard n_entries==0
Some checks failed
CI Code / Check spelling (pull_request) Failing after 22s
CI Code / Check coding style (pull_request) Successful in 34s
CI Code / Linux (debian) (pull_request) Successful in 5m23s
CI Code / Linux (arch) (pull_request) Successful in 6m1s
CI Code / Linux (ubuntu) (pull_request) Successful in 7m18s
CI Code / Code Coverage (pull_request) Successful in 7m32s
875f8f4100
Cursor was set to oldest of read window, not oldest of returned
batch — each page-up jumped past entire ranges (F16: 600/2000 msgs).
Always bisect now; correct + simpler. Cost: +5-10 ms per page-up at
1M msgs, well below UI threshold.

Also guard state->entries NULL when total_lines>0 && n_entries==0
(every line failed _ff_maybe_index_line). Backup would segfault.

F16 tightened to received == total_msgs. Catches both stuck-on-
entries[0] and cursor-jumping regressions.
jabber.developer2 added 1 commit 2026-05-04 12:28:53 +00:00
refactor(flatfile): drop dead cursor_offset, guard inverted range
Some checks failed
CI Code / Code Coverage (pull_request) Failing after 13m22s
CI Code / Check spelling (pull_request) Failing after 13m25s
CI Code / Check coding style (pull_request) Failing after 13m29s
CI Code / Linux (ubuntu) (pull_request) Failing after 13m32s
CI Code / Linux (debian) (pull_request) Failing after 13m36s
CI Code / Linux (arch) (pull_request) Failing after 13m39s
36d260db88
jabber.developer2 force-pushed feat/no-db-mode from 36d260db88 to 352eb26f71 2026-05-04 12:30:57 +00:00 Compare
jabber.developer2 added 1 commit 2026-05-04 12:49:43 +00:00
feat(history): autocomplete JID for verify/export/import
Some checks failed
CI Code / Code Coverage (pull_request) Failing after 12m11s
CI Code / Check spelling (pull_request) Failing after 12m14s
CI Code / Check coding style (pull_request) Failing after 12m19s
CI Code / Linux (ubuntu) (pull_request) Failing after 12m25s
CI Code / Linux (debian) (pull_request) Failing after 12m28s
CI Code / Linux (arch) (pull_request) Failing after 12m32s
52f97f7195
Author
Collaborator

feat(history): flat-file backend with bidirectional SQLite migration

A flat-file alternative to the SQLite chatlog backend with runtime
switching, full migration tooling, integrity verification, and a
synthetic load harness. SQLite remains the default; both backends share
one dispatch layer (db_backend_t vtable) so callers don't change.

Storage layout

  • Per-contact append-only flatlog/<account>/<contact>/history.log
    under XDG_DATA_HOME, one line per message
  • Single-line file header with embedded format-version marker
    (FLATFILE_FORMAT_VERSION); reader warns on missing or mismatched
    marker, writer and checker stay in sync via preprocessor
    stringification
  • Deterministic key=value metadata (id, aid, corrects, to,
    to_res, read) plus escaped body — \|, \], \\, \n, \r
    literals prevent log injection
  • Sparse byte-offset index (FF_INDEX_STEP=500) per contact for
    O(log n) time-range lookups; rebuilt on inode / size / mtime
    change, extended in-place when the file just grew
  • Per-contact GHashTable caches for archive_id presence and
    stanza_id → from_jid mapping (O(1) MAM dedup, O(1) LMC sender
    validation)

Hardening

  • Path-traversal protection: JID directory name normalisation
    (@_at_, slashes and .. rejected at construction); every
    per-contact path is anchored under the account's flatlog/
    directory and validated before open
  • Symlink-attack protection: every fopen / open uses O_NOFOLLOW; on
    ELOOP the operation aborts with an error rather than following
  • Filesystem permissions: log files created with mode 0600,
    directories with mode 0700; both enforced at creation, verified
    on each open and reported on drift by /history verify
  • Atomic crash-safe export: write to a temp file via mkstemp (mode
    0600, random suffix, no name collisions between concurrent
    exports), fsync, then rename — partial state never replaces the
    live file
  • Concurrency: advisory flock(LOCK_EX) held for the duration of
    every write, including append from live messages and full rewrite
    from export, so two profanity processes can't interleave bytes
    on the same log
  • DoS / abuse guards:
    • FF_MAX_LINE_LEN = 10 MB — lines longer than this are rejected
      at read with a warning; the parser will not allocate
      unbounded memory for a single record
    • FF_MAX_LMC_DEPTH = 100 — corrects: chain walk stops at this
      depth and emits a warning, preventing a malicious correction
      cycle from spinning the apply pass
    • FF_VERSION_SCAN_MAX = 16 — header version probe never reads
      past 16 leading comment lines, even on garbage input
    • Empty / inverted byte-range early-return in page-up read path
      so a malformed time filter cannot cause an unbounded scan
    • Zero-entry index guard so a file whose every line failed to
      parse cannot cause a NULL deref on later page-up
  • LMC sender validation: an incoming correction whose sender does
    not match the original message's sender is rejected at write
    time and surfaced via cons_show_error; a cycle in the apply pass
    is broken via a visited-set
  • jid_create_from_bare_and_resource treats NULL, empty string, and
    the literal "(null)" as no resource and returns a bare jid;
    similar normalisation for barejid eliminates the legacy
    "user@host/(null)" artefact that leaked into stored fulljids
    whenever g_strdup_printf("%s", NULL) ran inside create_fulljid

Commands

  • /history switch sqlite|flatfile — runtime backend swap, closes
    the old backend and opens the new one without reconnecting
  • /history export [<jid>] — SQLite -> flat-file, merging with any
    existing flatlog (dedup keyed on a SHA-256 hash mixing stanza_id,
    timestamp, from_jid, body — robust against id reuse by older
    clients)
  • /history import [<jid>] — flat-file -> SQLite, same merge
    semantics, runs inside a single SQLite transaction with rollback
    on per-contact failure
  • /history verify [<jid>] — integrity check; emits a structured
    list of issues (ERROR / WARNING / INFO) per file:
    • file-level: missing log, wrong permissions (≠ 0600), UTF-8
      BOM present, CRLF line endings, empty file
    • line-level: invalid UTF-8 (with byte offset), embedded
      control characters, unparsable lines, timestamps out of
      order, duplicate id: and aid: (tracked separately so a
      stanza/archive id collision isn't double-reported)
    • cross-line: broken corrects: references whose target id is
      not present in the file
  • /history backend — show currently active backend
  • Active backend indicator [sqlite] / [flatfile] in the status
    bar next to the JID
  • Roster-JID autocomplete for verify / export / import
  • export and import open a SQLite handle on demand when the
    flatfile backend is currently active, so migration works
    regardless of which backend is live

Tests

  • Unit: database_export (parser round-trip, escape/unescape, dedup
    key stability, JID normalisation), database_stress (14 cases
    exercising rapid writes, large messages, deep LMC chains, MAM
    dedup, concurrent contacts)
  • Functional: history persistence across reconnects, export /
    import round-trip with content equality, MUC migration,
    timestamp normalisation across timezones
  • Bench harness P1–P5 (synthetic load: bulk insert, time-range
    read, page-up scroll, MAM ingest, mixed workload) and failure
    modes F1–F17 (page-up cursor and forward-iteration symmetry,
    oversized lines, MAM dedup, LMC depth and cycles, BOM/CRLF,
    missing log, empty file, mtime+inode flip, broken corrects, etc.)
  • All bench tests integrate with the existing make targets and
    emit CSV rows for baseline comparison
feat(history): flat-file backend with bidirectional SQLite migration A flat-file alternative to the SQLite chatlog backend with runtime switching, full migration tooling, integrity verification, and a synthetic load harness. SQLite remains the default; both backends share one dispatch layer (db_backend_t vtable) so callers don't change. Storage layout - Per-contact append-only `flatlog/<account>/<contact>/history.log` under XDG_DATA_HOME, one line per message - Single-line file header with embedded format-version marker (FLATFILE_FORMAT_VERSION); reader warns on missing or mismatched marker, writer and checker stay in sync via preprocessor stringification - Deterministic key=value metadata (`id`, `aid`, `corrects`, `to`, `to_res`, `read`) plus escaped body — `\|`, `\]`, `\\`, `\n`, `\r` literals prevent log injection - Sparse byte-offset index (FF_INDEX_STEP=500) per contact for O(log n) time-range lookups; rebuilt on inode / size / mtime change, extended in-place when the file just grew - Per-contact GHashTable caches for archive_id presence and stanza_id → from_jid mapping (O(1) MAM dedup, O(1) LMC sender validation) Hardening - Path-traversal protection: JID directory name normalisation (`@` → `_at_`, slashes and `..` rejected at construction); every per-contact path is anchored under the account's flatlog/ directory and validated before open - Symlink-attack protection: every fopen / open uses O_NOFOLLOW; on ELOOP the operation aborts with an error rather than following - Filesystem permissions: log files created with mode 0600, directories with mode 0700; both enforced at creation, verified on each open and reported on drift by `/history verify` - Atomic crash-safe export: write to a temp file via mkstemp (mode 0600, random suffix, no name collisions between concurrent exports), fsync, then rename — partial state never replaces the live file - Concurrency: advisory flock(LOCK_EX) held for the duration of every write, including append from live messages and full rewrite from export, so two profanity processes can't interleave bytes on the same log - DoS / abuse guards: * FF_MAX_LINE_LEN = 10 MB — lines longer than this are rejected at read with a warning; the parser will not allocate unbounded memory for a single record * FF_MAX_LMC_DEPTH = 100 — `corrects:` chain walk stops at this depth and emits a warning, preventing a malicious correction cycle from spinning the apply pass * FF_VERSION_SCAN_MAX = 16 — header version probe never reads past 16 leading comment lines, even on garbage input * Empty / inverted byte-range early-return in page-up read path so a malformed time filter cannot cause an unbounded scan * Zero-entry index guard so a file whose every line failed to parse cannot cause a NULL deref on later page-up - LMC sender validation: an incoming correction whose sender does not match the original message's sender is rejected at write time and surfaced via cons_show_error; a cycle in the apply pass is broken via a visited-set - jid_create_from_bare_and_resource treats NULL, empty string, and the literal "(null)" as no resource and returns a bare jid; similar normalisation for barejid eliminates the legacy "user@host/(null)" artefact that leaked into stored fulljids whenever g_strdup_printf("%s", NULL) ran inside create_fulljid Commands - `/history switch sqlite|flatfile` — runtime backend swap, closes the old backend and opens the new one without reconnecting - `/history export [<jid>]` — SQLite -> flat-file, merging with any existing flatlog (dedup keyed on a SHA-256 hash mixing stanza_id, timestamp, from_jid, body — robust against id reuse by older clients) - `/history import [<jid>]` — flat-file -> SQLite, same merge semantics, runs inside a single SQLite transaction with rollback on per-contact failure - `/history verify [<jid>]` — integrity check; emits a structured list of issues (ERROR / WARNING / INFO) per file: * file-level: missing log, wrong permissions (≠ 0600), UTF-8 BOM present, CRLF line endings, empty file * line-level: invalid UTF-8 (with byte offset), embedded control characters, unparsable lines, timestamps out of order, duplicate `id:` and `aid:` (tracked separately so a stanza/archive id collision isn't double-reported) * cross-line: broken `corrects:` references whose target id is not present in the file - `/history backend` — show currently active backend - Active backend indicator `[sqlite]` / `[flatfile]` in the status bar next to the JID - Roster-JID autocomplete for verify / export / import - export and import open a SQLite handle on demand when the flatfile backend is currently active, so migration works regardless of which backend is live Tests - Unit: database_export (parser round-trip, escape/unescape, dedup key stability, JID normalisation), database_stress (14 cases exercising rapid writes, large messages, deep LMC chains, MAM dedup, concurrent contacts) - Functional: history persistence across reconnects, export / import round-trip with content equality, MUC migration, timestamp normalisation across timezones - Bench harness P1–P5 (synthetic load: bulk insert, time-range read, page-up scroll, MAM ingest, mixed workload) and failure modes F1–F17 (page-up cursor and forward-iteration symmetry, oversized lines, MAM dedup, LMC depth and cycles, BOM/CRLF, missing log, empty file, mtime+inode flip, broken corrects, etc.) - All bench tests integrate with the existing make targets and emit CSV rows for baseline comparison
jabber.developer2 added 3 commits 2026-05-04 14:19:23 +00:00
feat(history): /history backend command and status-bar indicator
Some checks failed
CI Code / Code Coverage (pull_request) Failing after 10m31s
CI Code / Check spelling (pull_request) Failing after 11m2s
CI Code / Check coding style (pull_request) Failing after 11m33s
CI Code / Linux (ubuntu) (pull_request) Failing after 12m2s
CI Code / Linux (debian) (pull_request) Failing after 12m33s
CI Code / Linux (arch) (pull_request) Failing after 13m5s
16aa569329
jabber.developer2 added 1 commit 2026-05-04 14:37:17 +00:00
fix(export): open sqlite on demand when flatfile is active backend
Some checks failed
CI Code / Code Coverage (pull_request) Failing after 12m27s
CI Code / Check spelling (pull_request) Failing after 12m56s
CI Code / Check coding style (pull_request) Failing after 13m33s
CI Code / Linux (ubuntu) (pull_request) Failing after 14m3s
CI Code / Linux (debian) (pull_request) Failing after 14m38s
CI Code / Linux (arch) (pull_request) Failing after 15m12s
09b2295414
jabber.developer2 added 2 commits 2026-05-04 15:31:07 +00:00
fix(jid): treat NULL/empty/"(null)" resource as bare jid
Some checks failed
CI Code / Code Coverage (pull_request) Failing after 13m44s
CI Code / Check spelling (pull_request) Failing after 14m13s
CI Code / Check coding style (pull_request) Failing after 14m43s
CI Code / Linux (ubuntu) (pull_request) Failing after 15m16s
CI Code / Linux (debian) (pull_request) Failing after 15m53s
CI Code / Linux (arch) (pull_request) Failing after 16m23s
3daee1216b
jabber.developer2 added 1 commit 2026-05-04 15:53:57 +00:00
fix(jid): treat NULL/empty/"(null)" barejid the same as resource
Some checks failed
CI Code / Check spelling (pull_request) Successful in 2m0s
CI Code / Check coding style (pull_request) Failing after 2m10s
CI Code / Linux (debian) (pull_request) Successful in 8m29s
CI Code / Linux (ubuntu) (pull_request) Successful in 8m33s
CI Code / Code Coverage (pull_request) Successful in 6m50s
CI Code / Linux (arch) (pull_request) Successful in 10m30s
b8a4900393
jabber.developer2 added 1 commit 2026-05-05 11:00:50 +00:00
refactor(flatfile): single-line file header with format-version marker
All checks were successful
CI Code / Check coding style (pull_request) Successful in 59s
CI Code / Check spelling (pull_request) Successful in 1m35s
CI Code / Linux (debian) (pull_request) Successful in 7m24s
CI Code / Linux (ubuntu) (pull_request) Successful in 7m24s
CI Code / Code Coverage (pull_request) Successful in 6m56s
CI Code / Linux (arch) (pull_request) Successful in 9m36s
b9c3267aad
jabber.developer2 force-pushed feat/no-db-mode from b9c3267aad to 2990ec795a 2026-05-05 11:57:50 +00:00 Compare
jabber.developer2 added 1 commit 2026-05-05 13:07:04 +00:00
fix(export): hash stanza_id with content instead of trusting it as sole dedup key
All checks were successful
CI Code / Check spelling (pull_request) Successful in 26s
CI Code / Check coding style (pull_request) Successful in 1m6s
CI Code / Code Coverage (pull_request) Successful in 2m44s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m50s
CI Code / Linux (arch) (pull_request) Successful in 5m25s
CI Code / Linux (debian) (pull_request) Successful in 6m50s
26db671a38
jabber.developer changed title from WIP: no-DB mode implementation to no-DB mode implementation 2026-05-05 19:31:15 +00:00
jabber.developer manually merged commit 3f36c303c2 into master 2026-05-05 19:32:42 +00:00
Sign in to join this conversation.
No description provided.