rust-py-audit 0.3.3__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/.github/workflows/release.yml +17 -7
  2. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/.gitignore +4 -0
  3. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/CHANGELOG.md +74 -0
  4. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/Cargo.lock +14 -3
  5. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/Cargo.toml +6 -1
  6. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/PKG-INFO +52 -3
  7. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/README.md +51 -2
  8. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/ROADMAP.md +13 -0
  9. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/pyproject.toml +1 -1
  10. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/python/rust_py_audit/__init__.py +1 -1
  11. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/src/audit_logger.rs +91 -20
  12. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/src/storage.rs +237 -18
  13. rust_py_audit-0.4.2/tests/test_multiprocess.py +80 -0
  14. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/LICENSE +0 -0
  15. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/Makefile +0 -0
  16. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/clippy.toml +0 -0
  17. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/examples/__init__.py +0 -0
  18. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/examples/basic_usage.py +0 -0
  19. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/examples/django_example/__init__.py +0 -0
  20. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/examples/django_example/settings.py +0 -0
  21. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/examples/django_example/urls.py +0 -0
  22. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/examples/django_example/views.py +0 -0
  23. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/examples/fastapi_app.py +0 -0
  24. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/examples/immutablelog_basic.py +0 -0
  25. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/python/rust_py_audit/django.py +0 -0
  26. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/python/rust_py_audit/fastapi.py +0 -0
  27. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/src/errors.rs +0 -0
  28. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/src/event.rs +0 -0
  29. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/src/hash.rs +0 -0
  30. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/src/immutablelog_client.rs +0 -0
  31. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/src/immutablelog_config.rs +0 -0
  32. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/src/immutablelog_receipt.rs +0 -0
  33. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/src/lib.rs +0 -0
  34. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/src/pyconv.rs +0 -0
  35. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/src/retry.rs +0 -0
  36. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/src/verifier.rs +0 -0
  37. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/tests/conftest.py +0 -0
  38. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/tests/test_audit_logger.py +0 -0
  39. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/tests/test_concurrency.py +0 -0
  40. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/tests/test_django_immutablelog.py +0 -0
  41. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/tests/test_django_middleware.py +0 -0
  42. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/tests/test_fastapi_immutablelog.py +0 -0
  43. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/tests/test_fastapi_middleware.py +0 -0
  44. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/tests/test_flush_pending.py +0 -0
  45. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/tests/test_hello.py +0 -0
  46. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/tests/test_immutablelog_hybrid.py +0 -0
  47. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/tests/test_immutablelog_remote.py +0 -0
  48. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/tests/test_metadata_bigint.py +0 -0
  49. {rust_py_audit-0.3.3 → rust_py_audit-0.4.2}/tests/test_verify.py +0 -0
@@ -141,21 +141,31 @@ jobs:
141
141
  path: dist
142
142
 
143
143
  macos:
144
+ # Um job só, os dois targets: o runner `macos-latest` é ARM e compila o
145
+ # x86_64 de forma cruzada. Antes era uma matriz de dois jobs, e cada
146
+ # release precisava de DUAS máquinas macOS — a fila mais disputada do
147
+ # GitHub Actions. Em 20/10/2026 dois releases (monitor 0.5.0, audit 0.4.1)
148
+ # ficaram parados com "The job was not acquired by Runner of type hosted
149
+ # even after multiple attempts" à espera da segunda.
144
150
  runs-on: macos-latest
145
151
  if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
146
152
  needs: [test, lint]
147
- strategy:
148
- matrix:
149
- target: [x86_64, aarch64]
150
153
  steps:
151
154
  - uses: actions/checkout@v4
152
- - uses: PyO3/maturin-action@v1
155
+ - name: Build wheels (x86_64)
156
+ uses: PyO3/maturin-action@v1
153
157
  with:
154
- target: ${{ matrix.target }}
158
+ target: x86_64
155
159
  args: --release --out dist --find-interpreter
156
- - uses: actions/upload-artifact@v4
160
+ - name: Build wheels (aarch64)
161
+ uses: PyO3/maturin-action@v1
162
+ with:
163
+ target: aarch64
164
+ args: --release --out dist --find-interpreter
165
+ - name: Upload wheels
166
+ uses: actions/upload-artifact@v4
157
167
  with:
158
- name: wheels-macos-${{ matrix.target }}
168
+ name: wheels-macos
159
169
  path: dist
160
170
 
161
171
  sdist:
@@ -9,3 +9,7 @@ build/
9
9
  *.pyd
10
10
  .pytest_cache/
11
11
  *.jsonl
12
+ *.jsonl.lock
13
+
14
+ # macOS
15
+ .DS_Store
@@ -5,6 +5,80 @@ Todas as mudanças relevantes deste projeto são registradas aqui.
5
5
  O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
6
6
  e o versionamento segue [SemVer](https://semver.org/lang/pt-BR/).
7
7
 
8
+ ## [0.4.2] — não publicada
9
+
10
+ Só CI: nada muda no comportamento da biblioteca nem nas wheels publicadas.
11
+
12
+ ### Alterado
13
+
14
+ - **Build macOS num job só.** O `release.yml` compilava `x86_64` e `aarch64`
15
+ numa matriz de dois jobs, e cada release precisava de duas máquinas macOS —
16
+ a fila mais disputada do GitHub Actions. Em 20/10/2026 dois releases
17
+ (`rust-py-monitor` 0.5.0 e `rust-py-audit` 0.4.1) ficaram parados com «The
18
+ job was not acquired by Runner of type hosted even after multiple attempts»
19
+ esperando a segunda máquina. Agora um único job em `macos-latest` (ARM)
20
+ compila os dois targets — o `x86_64` de forma cruzada, como já era feito —
21
+ e sobe um artefato `wheels-macos`. As wheels geradas são as mesmas.
22
+
23
+ ## [0.4.1] — não publicada
24
+
25
+ Resposta ao incidente da CA eMudhra / emSign (outubro de 2026).
26
+
27
+ ### Segurança
28
+
29
+ - **`webpki-roots` 1.0.8 → 1.0.10.** O cliente do ImmutableLog (`reqwest` com
30
+ `rustls-tls`) não usa o certifi nem o repositório de certificados do sistema:
31
+ as raízes confiáveis vêm do `webpki-roots`, **compiladas dentro da wheel**.
32
+ A 0.4.0 e anteriores embutiam a raiz **"emSign ECC Root CA - G3"**, removida
33
+ dos bundles de CA depois do comprometimento de chaves intermediárias da
34
+ eMudhra — confiar nela abre espaço para MITM na conexão com o ImmutableLog. A
35
+ 1.0.10 remove essa raiz (e a "Atos TrustedRoot 2011") e acrescenta SECOM TLS
36
+ 2024 e Telia TLS v3. Afeta só os modos `remote` e `hybrid`; `mode="local"`
37
+ não abre conexão nenhuma.
38
+ - Atualizar `pip`/`certifi` no ambiente **não** corrige versões anteriores da
39
+ biblioteca: é preciso instalar a 0.4.1.
40
+
41
+ ## [0.4.0] — não publicada
42
+
43
+ Cadeia de hashes segura com vários processos (**P2-4**).
44
+
45
+ ### Corrigido
46
+
47
+ - **Vários workers no mesmo JSONL quebravam a cadeia.** Cada processo
48
+ encadeava no `last_hash` que tinha em memória; com gunicorn ou
49
+ `uvicorn --workers`, dois eventos saíam com o mesmo `previous_hash` e o
50
+ `verify()` acusava `broken_chain` num arquivo que ninguém adulterou. O modo
51
+ de falha era silencioso até alguém rodar `verify()`. Reproduzido na 0.3.3
52
+ com 4 processos × 50 eventos: `{'valid': False, 'error_index': 1,
53
+ 'reason': 'broken_chain'}`. Na 0.4.0 o mesmo teste dá `valid: True` com os
54
+ 200 eventos.
55
+
56
+ ### Adicionado
57
+
58
+ - **Lock de arquivo entre processos** (`fs2`: `flock` no Unix, `LockFileEx` no
59
+ Windows) num `<file_path>.lock` ao lado do log — à parte, porque o
60
+ `flush_pending()` troca o JSONL por `rename` e um lock no próprio arquivo
61
+ ficaria preso ao inode antigo. Com o lock, o logger confere se o arquivo
62
+ cresceu desde o seu último append; se cresceu, relê o último hash **só do fim
63
+ do arquivo**. O handle do `.lock` fica aberto no logger, então o custo do
64
+ caminho comum não mudou (~29 µs por `log()`, medido antes e depois).
65
+ - **`fsync`** no construtor (padrão `False`). Com `True`, o `log()` só volta
66
+ depois de o evento estar no disco. Custa ~3,3 ms por evento no macOS
67
+ (`F_FULLFSYNC`), por isso é opt-in.
68
+ - A reescrita atômica do `flush_pending()` agora faz `sync_data` antes do
69
+ `rename`, para uma queda de energia não deixar o arquivo final truncado.
70
+
71
+ ### Compatibilidade
72
+
73
+ - Nenhum parâmetro existente mudou. Aparece um arquivo `<file_path>.lock` ao
74
+ lado do JSONL nos modos `local`/`hybrid`.
75
+ - Em `mode="hybrid"` com vários workers, a entrega ao ImmutableLog acontece
76
+ dentro do lock (o hash enviado depende do anterior), então os `log()` dos
77
+ workers passam a se serializar também no tempo de rede.
78
+ - Arquivos já gravados por vários workers em versões anteriores continuam
79
+ falhando no `verify()` a partir do primeiro entrelaçamento — o que eles têm
80
+ é o defeito antigo, não um efeito desta versão.
81
+
8
82
  ## [0.3.3] — não publicada
9
83
 
10
84
  Correção do passo de verificação pós-upload introduzido na 0.3.2, mais o
@@ -517,6 +517,16 @@ dependencies = [
517
517
  "percent-encoding",
518
518
  ]
519
519
 
520
+ [[package]]
521
+ name = "fs2"
522
+ version = "0.4.3"
523
+ source = "registry+https://github.com/rust-lang/crates.io-index"
524
+ checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
525
+ dependencies = [
526
+ "libc",
527
+ "winapi",
528
+ ]
529
+
520
530
  [[package]]
521
531
  name = "futures-channel"
522
532
  version = "0.3.32"
@@ -1597,8 +1607,9 @@ dependencies = [
1597
1607
 
1598
1608
  [[package]]
1599
1609
  name = "rust_py_audit"
1600
- version = "0.3.3"
1610
+ version = "0.4.2"
1601
1611
  dependencies = [
1612
+ "fs2",
1602
1613
  "httpmock",
1603
1614
  "pyo3",
1604
1615
  "reqwest",
@@ -2352,9 +2363,9 @@ dependencies = [
2352
2363
 
2353
2364
  [[package]]
2354
2365
  name = "webpki-roots"
2355
- version = "1.0.8"
2366
+ version = "1.0.10"
2356
2367
  source = "registry+https://github.com/rust-lang/crates.io-index"
2357
- checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
2368
+ checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
2358
2369
  dependencies = [
2359
2370
  "rustls-pki-types",
2360
2371
  ]
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "rust_py_audit"
3
- version = "0.3.3"
3
+ version = "0.4.2"
4
4
  edition = "2021"
5
5
  description = "Auditoria de eventos com cadeia de hashes (SHA-256) para apps Python, core em Rust via PyO3."
6
6
  license = "MIT"
@@ -60,6 +60,11 @@ time = { version = "0.3", features = ["formatting", "parsing", "serde-well-known
60
60
  # (OpenSSL via "native-tls") evita exigir libssl-dev/headers do sistema
61
61
  # em cada plataforma onde o wheel é compilado/distribuído.
62
62
  reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
63
+ # fs2: lock de arquivo entre processos (`flock` no Unix, `LockFileEx` no
64
+ # Windows). Serializa o append da cadeia quando vários workers (gunicorn,
65
+ # uvicorn --workers) gravam no mesmo JSONL. `File::lock` da std resolveria,
66
+ # mas só estabilizou no Rust 1.89 e a CI fixa 1.88.
67
+ fs2 = "0.4"
63
68
 
64
69
  [dev-dependencies]
65
70
  # httpmock: servidor HTTP local de mentirinha, usado só em `cargo test`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rust-py-audit
3
- Version: 0.3.3
3
+ Version: 0.4.2
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Programming Language :: Rust
@@ -54,7 +54,8 @@ Records audit events (`who did what, when, on which resource`) in a fast, struct
54
54
  - **`AuditLogger`** — simple API: `log(...)`, `verify()`, `last_hash()`
55
55
  - **Hash chain (SHA-256)** — each event embeds the hash of the previous event; altering any recorded event breaks the chain in a detectable way
56
56
  - **JSONL storage** — one event per line, append-only, no database required
57
- - **Thread-safe** — a single `AuditLogger` can be shared across threads (e.g. a multi-threaded WSGI server, or one middleware instance serving concurrent requests); the hash chain stays linear under concurrency
57
+ - **Thread-safe and multi-process-safe** — share one `AuditLogger` across threads, or point several worker processes (gunicorn, `uvicorn --workers`) at the same file: a cross-process file lock keeps the hash chain linear (see [Multiple workers](#multiple-workers))
58
+ - **Optional `fsync`** — `fsync=True` makes `log()` return only once the event is on disk, surviving a power loss
58
59
  - **Free-form `metadata`** — any JSON-serializable `dict` (IP, reason, request_id, etc.)
59
60
  - **FastAPI middleware** — automatically logs state-changing requests (POST/PUT/PATCH/DELETE)
60
61
  - **Django middleware** — same idea, supports WSGI and ASGI
@@ -143,6 +144,53 @@ If any event was edited, removed, or reordered:
143
144
  # or "reason": "broken_chain" (removed/reordered/forged event)
144
145
  ```
145
146
 
147
+ ### Multiple workers
148
+
149
+ Several processes can write to the same `file_path` — the usual gunicorn /
150
+ `uvicorn --workers N` setup with one `AuditLogger` (or middleware) per worker:
151
+
152
+ ```python
153
+ # every worker
154
+ audit = AuditLogger(app_name="billing-api", file_path="/var/log/billing/audit.jsonl")
155
+ ```
156
+
157
+ Each append takes an exclusive file lock on `<file_path>.lock` (created next to
158
+ the log; `flock` on Unix, `LockFileEx` on Windows). Holding it, the logger checks
159
+ whether the file grew since its own last write — meaning another worker wrote —
160
+ and, if so, re-reads the last hash from the **tail** of the file before chaining.
161
+ A single process never pays for that re-read, and the lock costs microseconds.
162
+
163
+ Before 0.4.0 each process chained onto the last hash it kept in memory, so two
164
+ workers produced events with the same `previous_hash` and `verify()` reported
165
+ `broken_chain` on a file nobody had tampered with. If you ran several workers on
166
+ an older version, `verify()` on that file will fail at the first point where two
167
+ workers interleaved; start a new file after upgrading.
168
+
169
+ Notes:
170
+
171
+ - The lock is advisory and needs a filesystem with working locks — a local disk,
172
+ not most NFS/SMB mounts.
173
+ - In `mode="hybrid"` the network delivery happens inside the lock (the event's
174
+ hash, sent to ImmutableLog, depends on the previous one), so concurrent
175
+ `log()` calls across workers serialize on delivery time too — bounded by
176
+ `timeout_ms`.
177
+ - `mode="remote"` writes no local file and takes no lock; chain continuity there
178
+ is ImmutableLog's job.
179
+
180
+ ### Durability (`fsync`)
181
+
182
+ By default `log()` returns once the event is handed to the operating system,
183
+ which survives the **process** crashing but not the **machine** losing power. For
184
+ a hard guarantee, pass `fsync=True`:
185
+
186
+ ```python
187
+ audit = AuditLogger(app_name="billing-api", fsync=True)
188
+ ```
189
+
190
+ Every append then waits for the disk. It is expensive — about 3 ms per event on
191
+ macOS (which uses `F_FULLFSYNC`) and typically 0.1–2 ms on Linux SSDs, versus
192
+ ~30 µs without it — so it is opt-in.
193
+
146
194
  ---
147
195
 
148
196
  ## FastAPI
@@ -339,7 +387,7 @@ audit.verify() # still valid, even after flush_pending()
339
387
 
340
388
  ## API Reference
341
389
 
342
- ### `AuditLogger(app_name, file_path="./audit.jsonl", mode=None, immutablelog_url=None, immutablelog_api_key=None, timeout_ms=500, retry_enabled=True, max_retries=3, immutablelog_env=None)`
390
+ ### `AuditLogger(app_name, file_path="./audit.jsonl", mode=None, immutablelog_url=None, immutablelog_api_key=None, timeout_ms=500, retry_enabled=True, max_retries=3, immutablelog_env=None, fsync=False)`
343
391
 
344
392
  | Parameter | Type | Description |
345
393
  |---|---|---|
@@ -352,6 +400,7 @@ audit.verify() # still valid, even after flush_pending()
352
400
  | `retry_enabled` | `bool` | If `True`, retries retryable errors (5xx, timeout) up to `max_retries` times |
353
401
  | `max_retries` | `int` | Maximum number of retries (in addition to the initial attempt) |
354
402
  | `immutablelog_env` | `str \| None` | Logical environment (`meta.env`, e.g. `"production"`). `None` falls back to `IMMUTABLELOG_ENV`; if neither is set, the field is omitted |
403
+ | `fsync` | `bool` | If `True`, every local append is flushed to disk before `log()` returns. See [Durability](#durability-fsync) |
355
404
 
356
405
  See [ImmutableLog Integration](#immutablelog-integration) for details on each mode.
357
406
 
@@ -18,7 +18,8 @@ Records audit events (`who did what, when, on which resource`) in a fast, struct
18
18
  - **`AuditLogger`** — simple API: `log(...)`, `verify()`, `last_hash()`
19
19
  - **Hash chain (SHA-256)** — each event embeds the hash of the previous event; altering any recorded event breaks the chain in a detectable way
20
20
  - **JSONL storage** — one event per line, append-only, no database required
21
- - **Thread-safe** — a single `AuditLogger` can be shared across threads (e.g. a multi-threaded WSGI server, or one middleware instance serving concurrent requests); the hash chain stays linear under concurrency
21
+ - **Thread-safe and multi-process-safe** — share one `AuditLogger` across threads, or point several worker processes (gunicorn, `uvicorn --workers`) at the same file: a cross-process file lock keeps the hash chain linear (see [Multiple workers](#multiple-workers))
22
+ - **Optional `fsync`** — `fsync=True` makes `log()` return only once the event is on disk, surviving a power loss
22
23
  - **Free-form `metadata`** — any JSON-serializable `dict` (IP, reason, request_id, etc.)
23
24
  - **FastAPI middleware** — automatically logs state-changing requests (POST/PUT/PATCH/DELETE)
24
25
  - **Django middleware** — same idea, supports WSGI and ASGI
@@ -107,6 +108,53 @@ If any event was edited, removed, or reordered:
107
108
  # or "reason": "broken_chain" (removed/reordered/forged event)
108
109
  ```
109
110
 
111
+ ### Multiple workers
112
+
113
+ Several processes can write to the same `file_path` — the usual gunicorn /
114
+ `uvicorn --workers N` setup with one `AuditLogger` (or middleware) per worker:
115
+
116
+ ```python
117
+ # every worker
118
+ audit = AuditLogger(app_name="billing-api", file_path="/var/log/billing/audit.jsonl")
119
+ ```
120
+
121
+ Each append takes an exclusive file lock on `<file_path>.lock` (created next to
122
+ the log; `flock` on Unix, `LockFileEx` on Windows). Holding it, the logger checks
123
+ whether the file grew since its own last write — meaning another worker wrote —
124
+ and, if so, re-reads the last hash from the **tail** of the file before chaining.
125
+ A single process never pays for that re-read, and the lock costs microseconds.
126
+
127
+ Before 0.4.0 each process chained onto the last hash it kept in memory, so two
128
+ workers produced events with the same `previous_hash` and `verify()` reported
129
+ `broken_chain` on a file nobody had tampered with. If you ran several workers on
130
+ an older version, `verify()` on that file will fail at the first point where two
131
+ workers interleaved; start a new file after upgrading.
132
+
133
+ Notes:
134
+
135
+ - The lock is advisory and needs a filesystem with working locks — a local disk,
136
+ not most NFS/SMB mounts.
137
+ - In `mode="hybrid"` the network delivery happens inside the lock (the event's
138
+ hash, sent to ImmutableLog, depends on the previous one), so concurrent
139
+ `log()` calls across workers serialize on delivery time too — bounded by
140
+ `timeout_ms`.
141
+ - `mode="remote"` writes no local file and takes no lock; chain continuity there
142
+ is ImmutableLog's job.
143
+
144
+ ### Durability (`fsync`)
145
+
146
+ By default `log()` returns once the event is handed to the operating system,
147
+ which survives the **process** crashing but not the **machine** losing power. For
148
+ a hard guarantee, pass `fsync=True`:
149
+
150
+ ```python
151
+ audit = AuditLogger(app_name="billing-api", fsync=True)
152
+ ```
153
+
154
+ Every append then waits for the disk. It is expensive — about 3 ms per event on
155
+ macOS (which uses `F_FULLFSYNC`) and typically 0.1–2 ms on Linux SSDs, versus
156
+ ~30 µs without it — so it is opt-in.
157
+
110
158
  ---
111
159
 
112
160
  ## FastAPI
@@ -303,7 +351,7 @@ audit.verify() # still valid, even after flush_pending()
303
351
 
304
352
  ## API Reference
305
353
 
306
- ### `AuditLogger(app_name, file_path="./audit.jsonl", mode=None, immutablelog_url=None, immutablelog_api_key=None, timeout_ms=500, retry_enabled=True, max_retries=3, immutablelog_env=None)`
354
+ ### `AuditLogger(app_name, file_path="./audit.jsonl", mode=None, immutablelog_url=None, immutablelog_api_key=None, timeout_ms=500, retry_enabled=True, max_retries=3, immutablelog_env=None, fsync=False)`
307
355
 
308
356
  | Parameter | Type | Description |
309
357
  |---|---|---|
@@ -316,6 +364,7 @@ audit.verify() # still valid, even after flush_pending()
316
364
  | `retry_enabled` | `bool` | If `True`, retries retryable errors (5xx, timeout) up to `max_retries` times |
317
365
  | `max_retries` | `int` | Maximum number of retries (in addition to the initial attempt) |
318
366
  | `immutablelog_env` | `str \| None` | Logical environment (`meta.env`, e.g. `"production"`). `None` falls back to `IMMUTABLELOG_ENV`; if neither is set, the field is omitted |
367
+ | `fsync` | `bool` | If `True`, every local append is flushed to disk before `log()` returns. See [Durability](#durability-fsync) |
319
368
 
320
369
  See [ImmutableLog Integration](#immutablelog-integration) for details on each mode.
321
370
 
@@ -34,6 +34,15 @@ what is done and the **directional** ideas under consideration.
34
34
 
35
35
  ---
36
36
 
37
+ ## Shipped in 0.4.0
38
+
39
+ - ✅ **Multi-process safety** — a cross-process file lock (`<file_path>.lock`)
40
+ serializes appends; a logger re-reads the last hash from the file tail when
41
+ another process wrote since its last append. Several gunicorn/uvicorn workers
42
+ can share one JSONL and `verify()` stays valid.
43
+ - ✅ **Optional `fsync`** (`AuditLogger(..., fsync=True)`), and the atomic
44
+ rewrite used by `flush_pending()` now syncs before its `rename`.
45
+
37
46
  ## Directional ideas (no version assigned — confirm before planning)
38
47
 
39
48
  - 💡 **Log rotation / segmentation** — roll the JSONL by size or date while
@@ -53,6 +62,10 @@ what is done and the **directional** ideas under consideration.
53
62
  ## Known limitations (by design, for now)
54
63
 
55
64
  - Local JSONL is single-file and append-only (no built-in rotation yet).
65
+ - The cross-process lock needs a filesystem with working advisory locks (local
66
+ disk; most NFS/SMB mounts don't qualify).
67
+ - In `mode="hybrid"`, delivery runs inside the cross-process lock, so workers
68
+ serialize on network time (bounded by `timeout_ms`).
56
69
  - `verify()` re-reads the entire file (O(n)); fine for typical logs, see the
57
70
  incremental-verification idea above for very large files.
58
71
  - ImmutableLog delivery is synchronous on the `log()` path (bounded by
@@ -6,7 +6,7 @@ build-backend = "maturin"
6
6
 
7
7
  [project]
8
8
  name = "rust-py-audit"
9
- version = "0.3.3"
9
+ version = "0.4.2"
10
10
  description = "Fast, tamper-evident event auditing (hash chain) for Python apps, with a Rust core."
11
11
  readme = "README.md"
12
12
  # Forma do PEP 639: expressão SPDX + arquivos de licença declarados. O
@@ -8,5 +8,5 @@ que existe um módulo nativo por trás.
8
8
 
9
9
  from ._rust_py_audit import AuditLogger, hello
10
10
 
11
- __version__ = "0.3.3"
11
+ __version__ = "0.4.2"
12
12
  __all__ = ["AuditLogger", "hello"]
@@ -71,14 +71,19 @@ use crate::verifier::verify_chain;
71
71
  /// segura o lock. Por isso `log()`/`flush_pending()`/`last_hash()`
72
72
  /// envolvem o uso do lock em `allow_threads`.
73
73
  ///
74
- /// Limitação consciente do MVP: esse cache assume que só **este**
75
- /// `AuditLogger` (este processo) escreve no arquivo. Se dois processos
76
- /// diferentes apontarem para o mesmo `file_path` e chamarem `log()`
77
- /// concorrentemente, o cache de um processo pode ficar desatualizado em
78
- /// relação ao que o outro gravou — a cadeia no arquivo continuaria
79
- /// íntegra (cada processo encadeia com o que *ele* sabia ser o último
80
- /// hash), mas teríamos duas pontas de cadeia em paralelo. Resolver isso
81
- /// exigiria lock de arquivo entre processos, fora do escopo do MVP.
74
+ /// Vários processos (desde a 0.4): o `Mutex` só serializa threads do
75
+ /// MESMO processo. Com vários workers (gunicorn, uvicorn --workers)
76
+ /// gravando no mesmo `file_path`, cada um tinha seu próprio `last_hash` em
77
+ /// memória — dois eventos acabavam com o mesmo `previous_hash` e
78
+ /// `verify()` acusava adulteração num arquivo que ninguém adulterou. Agora,
79
+ /// nos modos que gravam local (`local`/`hybrid`), a seção crítica também
80
+ /// segura um lock de arquivo entre processos (`storage::ChainLock`, num
81
+ /// `<file_path>.lock` ao lado) e, já com ele, confere se o arquivo cresceu
82
+ /// desde o nosso último append: se cresceu, outro processo gravou, e o
83
+ /// `last_hash` é relido do fim do arquivo (só a última linha). Se não
84
+ /// cresceu, o cache vale — o caso de um processo só continua O(1).
85
+ ///
86
+ /// Ordem dos locks: sempre o `Mutex` primeiro, depois o `ChainLock`.
82
87
  ///
83
88
  /// Limitação específica de `mode="remote"`: como esse modo NÃO grava
84
89
  /// JSONL local, `last_hash` vive apenas em memória. Ao recriar o
@@ -93,7 +98,14 @@ use crate::verifier::verify_chain;
93
98
  pub struct AuditLogger {
94
99
  app_name: String,
95
100
  file_path: PathBuf,
96
- last_hash: Mutex<Option<String>>,
101
+ last_hash: Mutex<ChainHead>,
102
+ /// `fsync` depois de cada append (padrão `false`). Com `true`, o
103
+ /// `log()` só volta depois de o evento estar no disco, não só no cache
104
+ /// do SO — ao custo de ~1–3 ms por evento (`F_FULLFSYNC` no macOS).
105
+ fsync: bool,
106
+ /// Handle do `<file_path>.lock`, aberto uma vez. `Some` nos modos que
107
+ /// gravam local; é o lock entre processos da cadeia.
108
+ lock_file: Option<std::fs::File>,
97
109
  /// Modo de operação (`local`/`remote`/`hybrid`). Por ora só fica
98
110
  /// guardado e exposto via getter — `log()` ainda se comporta como
99
111
  /// antes (sempre local) independente do valor aqui. O envio remoto
@@ -134,7 +146,9 @@ impl AuditLogger {
134
146
  retry_enabled = true,
135
147
  max_retries = 3,
136
148
  immutablelog_env = None,
149
+ fsync = false,
137
150
  ))]
151
+ #[allow(clippy::too_many_arguments)]
138
152
  fn new(
139
153
  app_name: String,
140
154
  file_path: String,
@@ -145,6 +159,7 @@ impl AuditLogger {
145
159
  retry_enabled: bool,
146
160
  max_retries: u8,
147
161
  immutablelog_env: Option<String>,
162
+ fsync: bool,
148
163
  ) -> PyResult<Self> {
149
164
  let file_path = PathBuf::from(file_path);
150
165
 
@@ -176,10 +191,22 @@ impl AuditLogger {
176
191
  .last()
177
192
  .map(|event| event.hash.clone());
178
193
 
194
+ let file_len = storage::file_len(&file_path)?;
195
+ let lock_file = if mode.saves_local_jsonl() {
196
+ Some(storage::open_lock_file(&file_path)?)
197
+ } else {
198
+ None
199
+ };
200
+
179
201
  Ok(AuditLogger {
180
202
  app_name,
181
203
  file_path,
182
- last_hash: Mutex::new(last_hash),
204
+ last_hash: Mutex::new(ChainHead {
205
+ hash: last_hash,
206
+ file_len,
207
+ }),
208
+ fsync,
209
+ lock_file,
183
210
  mode,
184
211
  immutablelog,
185
212
  client,
@@ -226,6 +253,11 @@ impl AuditLogger {
226
253
  self.immutablelog.env.clone()
227
254
  }
228
255
 
256
+ #[getter]
257
+ fn fsync(&self) -> bool {
258
+ self.fsync
259
+ }
260
+
229
261
  /// Devolve o hash do último evento gravado, ou `None` se o logger
230
262
  /// ainda não registrou nenhum evento (nem leu nenhum de um arquivo
231
263
  /// existente).
@@ -244,7 +276,7 @@ impl AuditLogger {
244
276
  /// O `allow_threads` em volta do lock segue a REGRA CRÍTICA descrita
245
277
  /// na doc da struct: nunca travar o `Mutex` segurando o GIL.
246
278
  fn last_hash(&self, py: Python<'_>) -> Option<String> {
247
- py.allow_threads(|| self.lock_last_hash().clone())
279
+ py.allow_threads(|| self.lock_last_hash().hash.clone())
248
280
  }
249
281
 
250
282
  /// Registra um novo evento de auditoria.
@@ -406,9 +438,21 @@ impl AuditLogger {
406
438
  // serializando contra um `log()` concorrente. As funções em
407
439
  // lote releem os arquivos aqui dentro, então eventos
408
440
  // acrescentados nesse meio-tempo são preservados.
409
- let _guard = self.lock_last_hash();
441
+ // O `ChainLock` também: outro worker pode estar anexando ao
442
+ // mesmo arquivo, e a reescrita (ler + trocar por `rename`)
443
+ // perderia o evento dele.
444
+ let mut guard = self.lock_last_hash();
445
+ let _file_lock = self
446
+ .lock_file
447
+ .as_ref()
448
+ .map(storage::ChainLock::acquire)
449
+ .transpose()?;
410
450
  storage::update_event_receipts(&self.file_path, &receipts)?;
411
451
  storage::remove_events(&pending_path, &handled)?;
452
+ // A reescrita muda o tamanho; o hash do fim continua o mesmo
453
+ // (receipt não entra no hash), mas o próximo `log()` não
454
+ // precisa reler a cauda por causa de uma mudança nossa.
455
+ guard.file_len = storage::file_len(&self.file_path)?;
412
456
 
413
457
  Ok((flushed, failed, total - flushed - failed))
414
458
  },
@@ -423,6 +467,14 @@ impl AuditLogger {
423
467
  }
424
468
  }
425
469
 
470
+ /// Ponta da cadeia como este processo a conhece: o hash do último evento e
471
+ /// o tamanho do arquivo logo depois de gravá-lo. Se o arquivo não tiver mais
472
+ /// esse tamanho, outro processo gravou e `hash` está velho.
473
+ struct ChainHead {
474
+ hash: Option<String>,
475
+ file_len: u64,
476
+ }
477
+
426
478
  /// Métodos auxiliares internos — fora do bloco `#[pymethods]` de
427
479
  /// propósito: nada aqui deve ser exposto como método Python de
428
480
  /// `AuditLogger`.
@@ -436,7 +488,7 @@ impl AuditLogger {
436
488
  ///
437
489
  /// IMPORTANTE: só chamar com o GIL liberado (ver REGRA CRÍTICA na
438
490
  /// doc da struct).
439
- fn lock_last_hash(&self) -> std::sync::MutexGuard<'_, Option<String>> {
491
+ fn lock_last_hash(&self) -> std::sync::MutexGuard<'_, ChainHead> {
440
492
  self.last_hash
441
493
  .lock()
442
494
  .unwrap_or_else(|poisoned| poisoned.into_inner())
@@ -498,6 +550,21 @@ impl AuditLogger {
498
550
  ) -> Result<AuditEvent, crate::errors::AuditError> {
499
551
  let mut guard = self.lock_last_hash();
500
552
 
553
+ // Modos que gravam local: lock entre processos e, se outro processo
554
+ // gravou desde o nosso último append (o arquivo mudou de tamanho),
555
+ // relê o `last_hash` do fim do arquivo antes de encadear.
556
+ let _file_lock = if let Some(lock_file) = &self.lock_file {
557
+ let lock = storage::ChainLock::acquire(lock_file)?;
558
+ let len = storage::file_len(&self.file_path)?;
559
+ if len != guard.file_len {
560
+ guard.hash = storage::read_last_hash(&self.file_path)?;
561
+ guard.file_len = len;
562
+ }
563
+ Some(lock)
564
+ } else {
565
+ None
566
+ };
567
+
501
568
  // `hash` começa vazio: só dá para calculá-lo depois que todos os
502
569
  // outros campos existem (`compute_hash` ignora o campo `hash`).
503
570
  let mut event = AuditEvent {
@@ -509,7 +576,7 @@ impl AuditLogger {
509
576
  resource,
510
577
  resource_id,
511
578
  metadata,
512
- previous_hash: guard.clone(),
579
+ previous_hash: guard.hash.clone(),
513
580
  hash: String::new(),
514
581
  severity,
515
582
  immutable_trail,
@@ -519,8 +586,8 @@ impl AuditLogger {
519
586
 
520
587
  match self.mode {
521
588
  AuditMode::Local => {
522
- storage::append_event(&self.file_path, &event)?;
523
- *guard = Some(event.hash.clone());
589
+ guard.file_len = storage::append_event(&self.file_path, &event, self.fsync)?;
590
+ guard.hash = Some(event.hash.clone());
524
591
  }
525
592
  AuditMode::Remote => {
526
593
  // Não grava JSONL local: se o envio falhar, o erro sobe
@@ -530,7 +597,7 @@ impl AuditLogger {
530
597
  .deliver(&event, resolved_severity, event.immutable_trail.as_deref())
531
598
  .map_err(crate::errors::AuditError::from)?;
532
599
  event.immutablelog = Some(receipt);
533
- *guard = Some(event.hash.clone());
600
+ guard.hash = Some(event.hash.clone());
534
601
  }
535
602
  AuditMode::Hybrid => {
536
603
  // Entrega ANTES de gravar, para anexar o evento ao JSONL
@@ -571,10 +638,14 @@ impl AuditLogger {
571
638
  // a ficar gravado como `pending` no arquivo principal sem
572
639
  // estar na fila (preso sem reenvio).
573
640
  if queue_for_retry {
574
- storage::append_event(&storage::pending_path_for(&self.file_path), &event)?;
641
+ storage::append_event(
642
+ &storage::pending_path_for(&self.file_path),
643
+ &event,
644
+ self.fsync,
645
+ )?;
575
646
  }
576
- storage::append_event(&self.file_path, &event)?;
577
- *guard = Some(event.hash.clone());
647
+ guard.file_len = storage::append_event(&self.file_path, &event, self.fsync)?;
648
+ guard.hash = Some(event.hash.clone());
578
649
  }
579
650
  }
580
651
 
@@ -1,7 +1,9 @@
1
1
  use std::fs::{File, OpenOptions};
2
- use std::io::{BufRead, BufReader, Write};
2
+ use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
3
3
  use std::path::{Path, PathBuf};
4
4
 
5
+ use fs2::FileExt;
6
+
5
7
  use crate::errors::AuditError;
6
8
  use crate::event::AuditEvent;
7
9
  use crate::immutablelog_receipt::ImmutableLogReceipt;
@@ -26,6 +28,133 @@ pub fn pending_path_for(path: &Path) -> PathBuf {
26
28
  }
27
29
  }
28
30
 
31
+ /// Caminho do arquivo de lock que serializa a cadeia entre processos:
32
+ /// `"./audit.jsonl"` -> `"./audit.jsonl.lock"`.
33
+ ///
34
+ /// O lock fica num arquivo À PARTE, e não no próprio JSONL, porque
35
+ /// `rewrite_events` troca o JSONL por `rename` — um lock preso ao inode
36
+ /// antigo deixaria de proteger o arquivo novo sem ninguém perceber.
37
+ pub fn lock_path_for(path: &Path) -> PathBuf {
38
+ let mut name = path
39
+ .file_name()
40
+ .map(|name| name.to_os_string())
41
+ .unwrap_or_else(|| "audit.jsonl".into());
42
+ name.push(".lock");
43
+ path.with_file_name(name)
44
+ }
45
+
46
+ /// Lock exclusivo entre processos sobre a cadeia de um `file_path`.
47
+ /// Liberado no `Drop` (e pelo SO, se o processo morrer segurando-o — não
48
+ /// há lock órfão a limpar à mão).
49
+ ///
50
+ /// É isto que mantém a cadeia linear com vários workers: sem o lock, cada
51
+ /// processo encadeava no `last_hash` que *ele* tinha em memória, dois
52
+ /// eventos acabavam com o mesmo `previous_hash` e `verify()` acusava
53
+ /// adulteração num arquivo que ninguém adulterou.
54
+ ///
55
+ /// O lock vale por *descrição de arquivo aberta*: threads do mesmo processo
56
+ /// compartilhando o handle não se excluem por ele — quem as serializa é o
57
+ /// `Mutex` do logger, sempre travado antes.
58
+ pub struct ChainLock<'a> {
59
+ file: &'a File,
60
+ }
61
+
62
+ impl<'a> ChainLock<'a> {
63
+ /// Bloqueia até obter o lock exclusivo sobre `lock_file` (aberto com
64
+ /// [`open_lock_file`]).
65
+ pub fn acquire(lock_file: &'a File) -> Result<Self, AuditError> {
66
+ lock_file
67
+ .lock_exclusive()
68
+ .map_err(|err| AuditError::Io(format!("lock da cadeia: {err}")))?;
69
+ Ok(ChainLock { file: lock_file })
70
+ }
71
+ }
72
+
73
+ impl Drop for ChainLock<'_> {
74
+ fn drop(&mut self) {
75
+ let _ = FileExt::unlock(self.file);
76
+ }
77
+ }
78
+
79
+ /// Abre (criando se preciso) o `.lock` de `path`. O logger abre uma vez e
80
+ /// guarda o handle: diferente do JSONL, o `.lock` nunca é trocado por
81
+ /// `rename`, então o handle continua válido — e o `log()` economiza um
82
+ /// `open` por evento.
83
+ pub fn open_lock_file(path: &Path) -> Result<File, AuditError> {
84
+ let lock_path = lock_path_for(path);
85
+ if let Some(parent) = lock_path.parent() {
86
+ if !parent.as_os_str().is_empty() {
87
+ std::fs::create_dir_all(parent).map_err(|err| AuditError::Io(err.to_string()))?;
88
+ }
89
+ }
90
+ OpenOptions::new()
91
+ .create(true)
92
+ .truncate(false)
93
+ .write(true)
94
+ .open(&lock_path)
95
+ .map_err(|err| AuditError::Io(format!("{lock_path:?}: {err}")))
96
+ }
97
+
98
+ /// Tamanho do arquivo em bytes; `0` se ele ainda não existe.
99
+ pub fn file_len(path: &Path) -> Result<u64, AuditError> {
100
+ match std::fs::metadata(path) {
101
+ Ok(meta) => Ok(meta.len()),
102
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(0),
103
+ Err(err) => Err(AuditError::Io(err.to_string())),
104
+ }
105
+ }
106
+
107
+ /// Hash do último evento do arquivo, lendo **só o fim** dele.
108
+ ///
109
+ /// Usado quando outro processo gravou desde o nosso último `log()`: em
110
+ /// vez de reler o arquivo inteiro (O(n)), lemos blocos de trás para a
111
+ /// frente até achar a última linha não vazia. Custa O(tamanho da última
112
+ /// linha).
113
+ pub fn read_last_hash(path: &Path) -> Result<Option<String>, AuditError> {
114
+ const CHUNK: u64 = 8 * 1024;
115
+
116
+ let mut file = match File::open(path) {
117
+ Ok(file) => file,
118
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
119
+ Err(err) => return Err(AuditError::Io(err.to_string())),
120
+ };
121
+ let io = |err: std::io::Error| AuditError::Io(err.to_string());
122
+
123
+ let mut pos = file.seek(SeekFrom::End(0)).map_err(io)?;
124
+ let mut tail: Vec<u8> = Vec::new();
125
+ loop {
126
+ // Fim útil = depois de descartar quebras de linha/espaços finais.
127
+ let end = tail
128
+ .iter()
129
+ .rposition(|b| !b.is_ascii_whitespace())
130
+ .map(|i| i + 1);
131
+ if let Some(end) = end {
132
+ if let Some(start) = tail[..end].iter().rposition(|&b| b == b'\n') {
133
+ return parse_hash(&tail[start + 1..end]).map(Some);
134
+ }
135
+ if pos == 0 {
136
+ return parse_hash(&tail[..end]).map(Some);
137
+ }
138
+ } else if pos == 0 {
139
+ return Ok(None); // arquivo vazio ou só com espaços
140
+ }
141
+
142
+ let step = CHUNK.min(pos);
143
+ pos -= step;
144
+ file.seek(SeekFrom::Start(pos)).map_err(io)?;
145
+ let mut chunk = vec![0u8; step as usize];
146
+ file.read_exact(&mut chunk).map_err(io)?;
147
+ chunk.extend_from_slice(&tail);
148
+ tail = chunk;
149
+ }
150
+ }
151
+
152
+ fn parse_hash(line: &[u8]) -> Result<String, AuditError> {
153
+ let event: AuditEvent = serde_json::from_slice(line)
154
+ .map_err(|err| AuditError::Serialization(format!("última linha: {err}")))?;
155
+ Ok(event.hash)
156
+ }
157
+
29
158
  /// Acrescenta um evento ao final do arquivo JSONL, criando o arquivo
30
159
  /// (e os diretórios pais, se preciso) caso ainda não existam.
31
160
  ///
@@ -39,7 +168,12 @@ pub fn pending_path_for(path: &Path) -> PathBuf {
39
168
  /// Recebe `path: &Path` (uma referência emprestada) — esta função não
40
169
  /// precisa ser dona do caminho, só precisa lê-lo para abrir o arquivo.
41
170
  /// Quem chama (o `AuditLogger`, na Etapa 7) continua dono do `PathBuf`.
42
- pub fn append_event(path: &Path, event: &AuditEvent) -> Result<(), AuditError> {
171
+ ///
172
+ /// Com `sync = true`, chama `sync_data()` (fsync) antes de devolver: o
173
+ /// evento está no disco, não só no cache do SO, e sobrevive a uma queda de
174
+ /// energia. Devolve o tamanho do arquivo depois da escrita, que o logger
175
+ /// usa para notar se outro processo gravou desde então.
176
+ pub fn append_event(path: &Path, event: &AuditEvent, sync: bool) -> Result<u64, AuditError> {
43
177
  if let Some(parent) = path.parent() {
44
178
  if !parent.as_os_str().is_empty() {
45
179
  std::fs::create_dir_all(parent).map_err(|err| AuditError::Io(err.to_string()))?;
@@ -67,8 +201,14 @@ pub fn append_event(path: &Path, event: &AuditEvent) -> Result<(), AuditError> {
67
201
  // processo Python morrer logo após o `log()` retornar.
68
202
  file.flush()
69
203
  .map_err(|err| AuditError::Io(err.to_string()))?;
204
+ if sync {
205
+ file.sync_data()
206
+ .map_err(|err| AuditError::Io(err.to_string()))?;
207
+ }
70
208
 
71
- Ok(())
209
+ file.metadata()
210
+ .map(|meta| meta.len())
211
+ .map_err(|err| AuditError::Io(err.to_string()))
72
212
  }
73
213
 
74
214
  /// Lê todos os eventos do arquivo JSONL, em ordem (do mais antigo para
@@ -139,6 +279,11 @@ fn rewrite_events(path: &Path, events: &[AuditEvent]) -> Result<(), AuditError>
139
279
  tmp_file
140
280
  .flush()
141
281
  .map_err(|err| AuditError::Io(err.to_string()))?;
282
+ // No disco antes do `rename`: senão uma queda de energia logo após a
283
+ // troca pode deixar o arquivo final vazio ou truncado.
284
+ tmp_file
285
+ .sync_data()
286
+ .map_err(|err| AuditError::Io(err.to_string()))?;
142
287
  drop(tmp_file);
143
288
 
144
289
  std::fs::rename(&tmp_path, path).map_err(|err| AuditError::Io(err.to_string()))?;
@@ -231,6 +376,78 @@ mod tests {
231
376
  use serde_json::json;
232
377
  use std::path::PathBuf;
233
378
 
379
+ #[test]
380
+ fn read_last_hash_reads_only_the_tail() {
381
+ let path = temp_path();
382
+ assert_eq!(read_last_hash(&path).unwrap(), None);
383
+
384
+ // Muitos eventos com metadata grande: o arquivo passa de vários
385
+ // blocos de 8 KiB, e a última linha cruza a fronteira de um bloco.
386
+ let mut last = String::new();
387
+ for i in 0..50 {
388
+ let mut event = sample_event("LOGIN", None);
389
+ event.metadata = json!({ "pad": "x".repeat(3_000 + i) });
390
+ event.hash = format!("hash-{i}");
391
+ append_event(&path, &event, false).unwrap();
392
+ last = event.hash.clone();
393
+ }
394
+ assert_eq!(read_last_hash(&path).unwrap(), Some(last));
395
+
396
+ // Quebras de linha extras no fim não confundem a leitura.
397
+ std::fs::OpenOptions::new()
398
+ .append(true)
399
+ .open(&path)
400
+ .unwrap()
401
+ .write_all(b"\n\n")
402
+ .unwrap();
403
+ assert_eq!(read_last_hash(&path).unwrap().as_deref(), Some("hash-49"));
404
+ let _ = std::fs::remove_file(&path);
405
+ }
406
+
407
+ #[test]
408
+ fn append_returns_the_new_length() {
409
+ let path = temp_path();
410
+ let len = append_event(&path, &sample_event("LOGIN", None), true).unwrap();
411
+ assert_eq!(len, file_len(&path).unwrap());
412
+ assert!(len > 0);
413
+ let _ = std::fs::remove_file(&path);
414
+ }
415
+
416
+ #[test]
417
+ fn chain_lock_is_exclusive_across_handles() {
418
+ // Dois `ChainLock` no mesmo processo usam descritores diferentes,
419
+ // o que exercita o mesmo caminho do flock entre processos.
420
+ let path = temp_path();
421
+ let first_file = open_lock_file(&path).unwrap();
422
+ let first = ChainLock::acquire(&first_file).unwrap();
423
+
424
+ let (tx, rx) = std::sync::mpsc::channel();
425
+ let path2 = path.clone();
426
+ let waiter = std::thread::spawn(move || {
427
+ let second_file = open_lock_file(&path2).unwrap();
428
+ let _second = ChainLock::acquire(&second_file).unwrap();
429
+ tx.send(()).unwrap();
430
+ });
431
+ assert!(
432
+ rx.recv_timeout(std::time::Duration::from_millis(200))
433
+ .is_err(),
434
+ "o segundo lock não podia ser obtido com o primeiro ativo"
435
+ );
436
+ drop(first);
437
+ rx.recv_timeout(std::time::Duration::from_secs(5))
438
+ .expect("o segundo lock deveria sair assim que o primeiro caísse");
439
+ waiter.join().unwrap();
440
+ let _ = std::fs::remove_file(lock_path_for(&path));
441
+ }
442
+
443
+ #[test]
444
+ fn lock_path_sits_next_to_the_log() {
445
+ assert_eq!(
446
+ lock_path_for(Path::new("/var/log/app/audit.jsonl")),
447
+ PathBuf::from("/var/log/app/audit.jsonl.lock")
448
+ );
449
+ }
450
+
234
451
  fn temp_path() -> PathBuf {
235
452
  // Usa uuid (já é dependência do projeto) para gerar um nome de
236
453
  // arquivo único por teste, evitando que testes rodando em
@@ -270,7 +487,7 @@ mod tests {
270
487
  let path = temp_path();
271
488
  let event = sample_event("LOGIN", None);
272
489
 
273
- append_event(&path, &event).expect("append não deveria falhar");
490
+ append_event(&path, &event, false).expect("append não deveria falhar");
274
491
  let events = read_events(&path).expect("read não deveria falhar");
275
492
 
276
493
  assert_eq!(events, vec![event]);
@@ -284,8 +501,8 @@ mod tests {
284
501
  let first = sample_event("LOGIN", None);
285
502
  let second = sample_event("DELETE_INVOICE", Some("hash-of-first"));
286
503
 
287
- append_event(&path, &first).expect("append não deveria falhar");
288
- append_event(&path, &second).expect("append não deveria falhar");
504
+ append_event(&path, &first, false).expect("append não deveria falhar");
505
+ append_event(&path, &second, false).expect("append não deveria falhar");
289
506
  let events = read_events(&path).expect("read não deveria falhar");
290
507
 
291
508
  assert_eq!(events, vec![first, second]);
@@ -325,8 +542,8 @@ mod tests {
325
542
  let path = temp_path();
326
543
  let first = sample_event("LOGIN", None);
327
544
  let second = sample_event("LOGOUT", Some(&first.hash));
328
- append_event(&path, &first).expect("append não deveria falhar");
329
- append_event(&path, &second).expect("append não deveria falhar");
545
+ append_event(&path, &first, false).expect("append não deveria falhar");
546
+ append_event(&path, &second, false).expect("append não deveria falhar");
330
547
 
331
548
  let receipt = crate::immutablelog_receipt::ImmutableLogReceipt {
332
549
  status: "delivered".to_string(),
@@ -351,7 +568,8 @@ mod tests {
351
568
  #[test]
352
569
  fn update_event_receipt_fails_for_unknown_event_id() {
353
570
  let path = temp_path();
354
- append_event(&path, &sample_event("LOGIN", None)).expect("append não deveria falhar");
571
+ append_event(&path, &sample_event("LOGIN", None), false)
572
+ .expect("append não deveria falhar");
355
573
 
356
574
  let receipt = crate::immutablelog_receipt::ImmutableLogReceipt::pending();
357
575
  let result = update_event_receipt(&path, "evento-inexistente", &receipt);
@@ -367,9 +585,9 @@ mod tests {
367
585
  let first = sample_event("LOGIN", None);
368
586
  let second = sample_event("LOGOUT", Some(&first.hash));
369
587
  let third = sample_event("DELETE", Some(&second.hash));
370
- append_event(&path, &first).expect("append não deveria falhar");
371
- append_event(&path, &second).expect("append não deveria falhar");
372
- append_event(&path, &third).expect("append não deveria falhar");
588
+ append_event(&path, &first, false).expect("append não deveria falhar");
589
+ append_event(&path, &second, false).expect("append não deveria falhar");
590
+ append_event(&path, &third, false).expect("append não deveria falhar");
373
591
 
374
592
  let mut receipts = std::collections::HashMap::new();
375
593
  receipts.insert(first.id.clone(), ImmutableLogReceipt::pending());
@@ -408,9 +626,9 @@ mod tests {
408
626
  let first = sample_event("LOGIN", None);
409
627
  let second = sample_event("LOGOUT", Some(&first.hash));
410
628
  let third = sample_event("DELETE", Some(&second.hash));
411
- append_event(&path, &first).expect("append não deveria falhar");
412
- append_event(&path, &second).expect("append não deveria falhar");
413
- append_event(&path, &third).expect("append não deveria falhar");
629
+ append_event(&path, &first, false).expect("append não deveria falhar");
630
+ append_event(&path, &second, false).expect("append não deveria falhar");
631
+ append_event(&path, &third, false).expect("append não deveria falhar");
414
632
 
415
633
  let mut ids = std::collections::HashSet::new();
416
634
  ids.insert(first.id.clone());
@@ -426,7 +644,8 @@ mod tests {
426
644
  #[test]
427
645
  fn batch_helpers_are_noops_when_empty() {
428
646
  let path = temp_path();
429
- append_event(&path, &sample_event("LOGIN", None)).expect("append não deveria falhar");
647
+ append_event(&path, &sample_event("LOGIN", None), false)
648
+ .expect("append não deveria falhar");
430
649
 
431
650
  update_event_receipts(&path, &std::collections::HashMap::new()).expect("noop");
432
651
  remove_events(&path, &std::collections::HashSet::new()).expect("noop");
@@ -442,8 +661,8 @@ mod tests {
442
661
  let path = temp_path();
443
662
  let first = sample_event("LOGIN", None);
444
663
  let second = sample_event("LOGOUT", Some(&first.hash));
445
- append_event(&path, &first).expect("append não deveria falhar");
446
- append_event(&path, &second).expect("append não deveria falhar");
664
+ append_event(&path, &first, false).expect("append não deveria falhar");
665
+ append_event(&path, &second, false).expect("append não deveria falhar");
447
666
 
448
667
  remove_event(&path, &first.id).expect("remove não deveria falhar");
449
668
 
@@ -0,0 +1,80 @@
1
+ """Vários processos gravando no mesmo JSONL (gunicorn com N workers).
2
+
3
+ Antes da 0.4, cada processo encadeava no `last_hash` que tinha em memória:
4
+ dois eventos saíam com o mesmo `previous_hash` e `verify()` acusava
5
+ adulteração num arquivo que ninguém tinha adulterado. O lock de arquivo entre
6
+ processos mantém a cadeia linear.
7
+ """
8
+
9
+ import multiprocessing
10
+ import os
11
+
12
+ from rust_py_audit import AuditLogger
13
+
14
+ WORKERS = 4
15
+ EVENTS_PER_WORKER = 50
16
+
17
+
18
+ def _worker(path, worker_id, ready, go):
19
+ audit = AuditLogger("app", path)
20
+ ready.wait() # todos abriram o logger antes de qualquer um gravar
21
+ go.wait()
22
+ for i in range(EVENTS_PER_WORKER):
23
+ audit.log(f"worker-{worker_id}", "TICK", "job", str(i))
24
+
25
+
26
+ def _run_workers(path):
27
+ ctx = multiprocessing.get_context("spawn")
28
+ ready = ctx.Barrier(WORKERS)
29
+ go = ctx.Barrier(WORKERS)
30
+ procs = [ctx.Process(target=_worker, args=(path, w, ready, go)) for w in range(WORKERS)]
31
+ for p in procs:
32
+ p.start()
33
+ for p in procs:
34
+ p.join(timeout=60)
35
+ assert p.exitcode == 0
36
+
37
+
38
+ def test_concurrent_processes_keep_a_single_valid_chain(tmp_path):
39
+ path = str(tmp_path / "audit.jsonl")
40
+ _run_workers(path)
41
+
42
+ result = AuditLogger("app", path).verify()
43
+ assert result["valid"] is True, result
44
+ assert result["total_events"] == WORKERS * EVENTS_PER_WORKER
45
+
46
+
47
+ def test_logger_picks_up_events_written_by_another_process(tmp_path):
48
+ path = str(tmp_path / "audit.jsonl")
49
+ mine = AuditLogger("app", path)
50
+ mine.log("me", "FIRST", "doc", "1")
51
+
52
+ # Outro processo grava depois que `mine` já cacheou o último hash.
53
+ ctx = multiprocessing.get_context("spawn")
54
+ ready, go = ctx.Barrier(1), ctx.Barrier(1)
55
+ other = ctx.Process(target=_worker, args=(path, 9, ready, go))
56
+ other.start()
57
+ other.join(timeout=60)
58
+ assert other.exitcode == 0
59
+
60
+ event = mine.log("me", "AFTER", "doc", "2")
61
+ result = mine.verify()
62
+ assert result["valid"] is True, result
63
+ assert result["total_events"] == 2 + EVENTS_PER_WORKER
64
+ # Encadeou no último evento do OUTRO processo, não no próprio cache.
65
+ assert event["previous_hash"] is not None
66
+ assert mine.last_hash() == event["hash"]
67
+
68
+
69
+ def test_lock_file_sits_next_to_the_log(tmp_path):
70
+ path = tmp_path / "audit.jsonl"
71
+ AuditLogger("app", str(path)).log("a", "B", "c", "d")
72
+ assert os.path.exists(str(path) + ".lock")
73
+
74
+
75
+ def test_fsync_is_opt_in(tmp_path):
76
+ assert AuditLogger("app", str(tmp_path / "a.jsonl")).fsync is False
77
+ durable = AuditLogger("app", str(tmp_path / "b.jsonl"), fsync=True)
78
+ assert durable.fsync is True
79
+ durable.log("a", "B", "c", "d")
80
+ assert durable.verify()["valid"] is True
File without changes
File without changes
File without changes
File without changes
File without changes